2
0
Эх сурвалжийг харах

New and Improved Scoreboard System

 - Scoreboards now AUTO-UPDATE
 - Scoreboards now COME IN COLOR
 - If you want, they can come in EVERY COLOR (Config setting)
 - Scoreboards can be displayed alongside chat output!
 - Prevention of denial of service to SQL via spamming /mctop using a cooldown
 - Added /mccooldown command to show cooldowns for all available skills
riking 11 жил өмнө
parent
commit
95f15e68fe
33 өөрчлөгдсөн 1490 нэмэгдсэн , 541 устгасан
  1. 7 0
      Changelog.txt
  2. 2 2
      src/main/java/com/gmail/nossr50/api/ExperienceAPI.java
  3. 38 106
      src/main/java/com/gmail/nossr50/commands/McscoreboardCommand.java
  4. 6 10
      src/main/java/com/gmail/nossr50/commands/player/InspectCommand.java
  5. 99 0
      src/main/java/com/gmail/nossr50/commands/player/MccooldownCommand.java
  6. 15 17
      src/main/java/com/gmail/nossr50/commands/player/McrankCommand.java
  7. 14 18
      src/main/java/com/gmail/nossr50/commands/player/McstatsCommand.java
  8. 34 25
      src/main/java/com/gmail/nossr50/commands/player/MctopCommand.java
  9. 5 7
      src/main/java/com/gmail/nossr50/commands/skills/SkillCommand.java
  10. 61 21
      src/main/java/com/gmail/nossr50/config/Config.java
  11. 7 3
      src/main/java/com/gmail/nossr50/database/DatabaseManager.java
  12. 16 7
      src/main/java/com/gmail/nossr50/database/FlatfileDatabaseManager.java
  13. 6 6
      src/main/java/com/gmail/nossr50/database/SQLDatabaseManager.java
  14. 9 0
      src/main/java/com/gmail/nossr50/datatypes/player/McMMOPlayer.java
  15. 67 1
      src/main/java/com/gmail/nossr50/datatypes/skills/AbilityType.java
  16. 0 2
      src/main/java/com/gmail/nossr50/listeners/PlayerListener.java
  17. 49 0
      src/main/java/com/gmail/nossr50/listeners/ScoreboardsListener.java
  18. 2 0
      src/main/java/com/gmail/nossr50/locale/LocaleLoader.java
  19. 9 0
      src/main/java/com/gmail/nossr50/mcMMO.java
  20. 12 3
      src/main/java/com/gmail/nossr50/runnables/commands/McrankCommandAsyncTask.java
  21. 30 4
      src/main/java/com/gmail/nossr50/runnables/commands/McrankCommandDisplayTask.java
  22. 14 5
      src/main/java/com/gmail/nossr50/runnables/commands/MctopCommandAsyncTask.java
  23. 39 13
      src/main/java/com/gmail/nossr50/runnables/commands/MctopCommandDisplayTask.java
  24. 14 0
      src/main/java/com/gmail/nossr50/runnables/player/PowerLevelUpdatingTask.java
  25. 0 27
      src/main/java/com/gmail/nossr50/runnables/scoreboards/ScoreboardChangeTask.java
  26. 1 0
      src/main/java/com/gmail/nossr50/util/Misc.java
  27. 12 2
      src/main/java/com/gmail/nossr50/util/commands/CommandRegistrationManager.java
  28. 4 4
      src/main/java/com/gmail/nossr50/util/commands/CommandUtils.java
  29. 278 219
      src/main/java/com/gmail/nossr50/util/scoreboards/ScoreboardManager.java
  30. 543 0
      src/main/java/com/gmail/nossr50/util/scoreboards/ScoreboardWrapper.java
  31. 56 29
      src/main/resources/config.yml
  32. 34 9
      src/main/resources/locale/locale_en_US.properties
  33. 7 1
      src/main/resources/plugin.yml

+ 7 - 0
Changelog.txt

@@ -31,6 +31,11 @@ Version 1.4.07-dev
  + Added ability to give lore to items in treasures.yml - use the key "Lore" to set, expects a list of strings.
  + Added Quartz and Name Tags to the default Excavation treasures
  + Added a warning message if the server is running NoCheatPlus without CompatNoCheatPlus
+ + Added cooldown to commands with heavy database access to prevent denial of service
+ + Added /mcscoreboard keep, to keep the scoreboard up forever
+ + Added Rainbow Mode to scoreboards
+ + Added new /mccooldowns command to show all ability cooldowns
+ + Commands may now both print text and display a scoreboard
  + Killing a custom entity will automatically add it to the custom entity config file with default values.
  = Fixed bug which allowed players to bypass fishing's exploit prevention
  = Fixed bug where FakeEntityDamageByEntityEvent wasn't being fired
@@ -71,6 +76,8 @@ Version 1.4.07-dev
  ! Party item share category states are now saved when the server shuts down.
  ! When using "Super Breaker" or "Giga Driller" abilities extra tool durability is used (again)
  ! Mob healthbars are automatically disabled when the plugin "HealthBar" is found
+ ! Massively improved scoreboard handling
+ ! Reworked scoreboard configuration (config.yml) - **you will need to update**
  - The /mmoupdate command has been removed. It is replaced by /mcconvert database
  - Removed Abilities.Tools.Durability_Loss_Enabled, set Abilities.Tools.Durability_Loss to 0 to disable instead.
  - Removed Skills.Fishing.Shake_UnlockLevel from advanced.yml, now using Skills.Fishing.Rank_Levels.Rank_1 instead.

+ 2 - 2
src/main/java/com/gmail/nossr50/api/ExperienceAPI.java

@@ -461,7 +461,7 @@ public final class ExperienceAPI {
      * @return the position on the leaderboard
      */
     public static int getPlayerRankSkill(String playerName, String skillType) {
-        return mcMMO.getDatabaseManager().readRank(getOfflineProfile(playerName).getPlayerName()).get(getNonChildSkillType(skillType).toString());
+        return mcMMO.getDatabaseManager().readRank(getOfflineProfile(playerName).getPlayerName()).get(getNonChildSkillType(skillType));
     }
 
 
@@ -477,7 +477,7 @@ public final class ExperienceAPI {
      * @return the position on the power level leaderboard
      */
     public static int getPlayerRankOverall(String playerName) {
-        return mcMMO.getDatabaseManager().readRank(getOfflineProfile(playerName).getPlayerName()).get("ALL");
+        return mcMMO.getDatabaseManager().readRank(getOfflineProfile(playerName).getPlayerName()).get(null);
     }
 
     /**

+ 38 - 106
src/main/java/com/gmail/nossr50/commands/McscoreboardCommand.java

@@ -2,141 +2,73 @@ package com.gmail.nossr50.commands;
 
 import java.util.ArrayList;
 import java.util.List;
-
 import org.bukkit.command.Command;
 import org.bukkit.command.CommandSender;
 import org.bukkit.command.TabExecutor;
-import org.bukkit.entity.Player;
 import org.bukkit.util.StringUtil;
 
-import com.gmail.nossr50.mcMMO;
 import com.gmail.nossr50.config.Config;
-import com.gmail.nossr50.datatypes.skills.SkillType;
-import com.gmail.nossr50.util.StringUtils;
+import com.gmail.nossr50.locale.LocaleLoader;
 import com.gmail.nossr50.util.commands.CommandUtils;
-import com.gmail.nossr50.util.player.UserManager;
 import com.gmail.nossr50.util.scoreboards.ScoreboardManager;
-
 import com.google.common.collect.ImmutableList;
 
 public class McscoreboardCommand implements TabExecutor {
-    private static final List<String> SCOREBOARD_TYPES = ImmutableList.of("clear", "rank", "stats", "top");
+    private static final List<String> FIRST_ARGS = ImmutableList.of("keep", "time", "clear", "reset");
 
     @Override
     public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
         if (CommandUtils.noConsoleUsage(sender)) {
             return true;
         }
+        if (args.length == 0) {
+            help(sender);
+            return true;
+        }
 
-        Player player = (Player) sender;
-
-        switch (args.length) {
-            case 0:
-                clearScoreboard(player);
-                return true;
-
-            case 1:
-                if (args[0].equalsIgnoreCase("clear")) {
-                    clearScoreboard(player);
-                }
-                else if (args[0].equalsIgnoreCase("rank")) {
-                    if (!Config.getInstance().getMcrankScoreboardEnabled()) {
-                        sender.sendMessage("This scoreboard is not enabled."); //TODO: Localize
-                        return true;
-                    }
-
-                    ScoreboardManager.setupPlayerScoreboard(player.getName());
-                    ScoreboardManager.enablePlayerRankScoreboard(player);
-                }
-                else if (args[0].equalsIgnoreCase("stats")) {
-                    if (!Config.getInstance().getMcstatsScoreboardsEnabled()) {
-                        sender.sendMessage("This scoreboard is not enabled."); //TODO: Localize
-                        return true;
-                    }
-
-                    ScoreboardManager.setupPlayerScoreboard(player.getName());
-                    ScoreboardManager.enablePlayerStatsScoreboard(UserManager.getPlayer(player));
-                }
-                else if (args[0].equalsIgnoreCase("top")) {
-                    if (!Config.getInstance().getMctopScoreboardEnabled()) {
-                        sender.sendMessage("This scoreboard is not enabled."); //TODO: Localize
-                        return true;
-                    }
-
-                    ScoreboardManager.enableGlobalStatsScoreboard(player, "all", 1);
-                }
-                else {
-                    return false;
-                }
-
+        if (args[0].equalsIgnoreCase("clear") || args[0].equalsIgnoreCase("reset")) {
+            ScoreboardManager.clearBoard(sender.getName());
+            sender.sendMessage(LocaleLoader.getString("Commands.Scoreboard.Clear"));
+        }
+        else if (args[0].equalsIgnoreCase("keep")) {
+            if (!Config.getInstance().getAllowKeepBoard()) {
+                sender.sendMessage(LocaleLoader.getString("Commands.Disabled"));
                 return true;
-
-            case 2:
-                if (!args[0].equalsIgnoreCase("top")) {
-                    return false;
-                }
-
-                if (!Config.getInstance().getMctopScoreboardEnabled()) {
-                    sender.sendMessage("This scoreboard is not enabled."); //TODO: Localize
-                    return true;
-                }
-
-                if (StringUtils.isInt(args[1])) {
-                    ScoreboardManager.enableGlobalStatsScoreboard(player, "all", Math.abs(Integer.parseInt(args[1])));
-                    return true;
-                }
-
-                if (CommandUtils.isInvalidSkill(sender, args[1])) {
-                    return true;
-                }
-
-                ScoreboardManager.enableGlobalStatsScoreboard(player, args[1], 1);
+            }
+            ScoreboardManager.keepBoard(sender.getName());
+            sender.sendMessage(LocaleLoader.getString("Commands.Scoreboard.Keep"));
+        }
+        else if (args[0].equalsIgnoreCase("time") || args[0].equalsIgnoreCase("timer")) {
+            if (args.length == 1) {
+                help(sender);
                 return true;
-
-            case 3:
-                if (!args[0].equalsIgnoreCase("top")) {
-                    return false;
-                }
-
-                if (!Config.getInstance().getMctopScoreboardEnabled()) {
-                    sender.sendMessage("This scoreboard is not enabled."); //TODO: Localize
-                    return true;
-                }
-
-                if (CommandUtils.isInvalidSkill(sender, args[1])) {
-                    return true;
-                }
-
-                if (CommandUtils.isInvalidInteger(sender, args[2])) {
-                    return true;
-                }
-
-                ScoreboardManager.enableGlobalStatsScoreboard(player, args[1], Math.abs(Integer.parseInt(args[2])));
+            }
+            if (CommandUtils.isInvalidInteger(sender, args[1])) {
                 return true;
-
-            default:
-                return false;
+            }
+            ScoreboardManager.setRevertTimer(sender.getName(), Math.abs(Integer.parseInt(args[1])));
         }
+        else {
+            help(sender);
+        }
+        return true;
+    }
+
+    private void help(CommandSender sender) {
+        sender.sendMessage(LocaleLoader.getString("Commands.Scoreboard.Help.0"));
+        sender.sendMessage(LocaleLoader.getString("Commands.Scoreboard.Help.1"));
+        sender.sendMessage(LocaleLoader.getString("Commands.Scoreboard.Help.2"));
+        sender.sendMessage(LocaleLoader.getString("Commands.Scoreboard.Help.3"));
     }
 
     @Override
     public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
         switch (args.length) {
             case 1:
-                return StringUtil.copyPartialMatches(args[0], SCOREBOARD_TYPES, new ArrayList<String>(SCOREBOARD_TYPES.size()));
-            case 2:
-                if (args[0].equalsIgnoreCase("top")) {
-                    return StringUtil.copyPartialMatches(args[1], SkillType.SKILL_NAMES, new ArrayList<String>(SkillType.SKILL_NAMES.size()));
-                }
-                // Fallthrough
-
+                return StringUtil.copyPartialMatches(args[0], FIRST_ARGS, new ArrayList<String>(FIRST_ARGS.size()));
             default:
-                return ImmutableList.of();
+                break;
         }
-    }
-
-    private void clearScoreboard(Player player) {
-        player.setScoreboard(mcMMO.p.getServer().getScoreboardManager().getMainScoreboard());
-        player.sendMessage("Your scoreboard has been cleared!"); //TODO: Locale
+        return ImmutableList.of();
     }
 }

+ 6 - 10
src/main/java/com/gmail/nossr50/commands/player/InspectCommand.java

@@ -29,10 +29,6 @@ public class InspectCommand implements TabExecutor {
     public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
         switch (args.length) {
             case 1:
-                if (sender instanceof Player && Config.getInstance().getInspectScoreboardEnabled()) {
-                    ScoreboardManager.setupPlayerScoreboard(sender.getName());
-                }
-
                 String playerName = Misc.getMatchedPlayerName(args[0]);
                 McMMOPlayer mcMMOPlayer = UserManager.getPlayer(playerName, true);
 
@@ -44,9 +40,9 @@ public class InspectCommand implements TabExecutor {
                         return true;
                     }
 
-                    if (sender instanceof Player && Config.getInstance().getInspectScoreboardEnabled()) {
-                        ScoreboardManager.enablePlayerInspectScoreboardOffline((Player) sender, profile);
-                        return true;
+                    if (sender instanceof Player && Config.getInstance().getInspectUseBoard()) {
+                        ScoreboardManager.enablePlayerInspectScoreboard((Player) sender, profile);
+                        if (!Config.getInstance().getInspectUseChat()) return true;
                     }
 
                     sender.sendMessage(LocaleLoader.getString("Inspect.OfflineStats", playerName));
@@ -80,9 +76,9 @@ public class InspectCommand implements TabExecutor {
                         return true;
                     }
 
-                    if (sender instanceof Player && Config.getInstance().getInspectScoreboardEnabled()) {
-                        ScoreboardManager.enablePlayerInspectScoreboardOnline((Player) sender, mcMMOPlayer);
-                        return true;
+                    if (sender instanceof Player && Config.getInstance().getInspectUseBoard()) {
+                        ScoreboardManager.enablePlayerInspectScoreboard((Player) sender, mcMMOPlayer.getProfile());
+                        if (!Config.getInstance().getInspectUseChat()) return true;
                     }
 
                     sender.sendMessage(LocaleLoader.getString("Inspect.Stats", target.getName()));

+ 99 - 0
src/main/java/com/gmail/nossr50/commands/player/MccooldownCommand.java

@@ -0,0 +1,99 @@
+package com.gmail.nossr50.commands.player;
+
+import java.util.List;
+
+import org.bukkit.command.Command;
+import org.bukkit.command.CommandSender;
+import org.bukkit.command.TabExecutor;
+import org.bukkit.entity.Player;
+import org.bukkit.permissions.Permissible;
+
+import com.gmail.nossr50.mcMMO;
+import com.gmail.nossr50.config.Config;
+import com.gmail.nossr50.datatypes.player.PlayerProfile;
+import com.gmail.nossr50.datatypes.skills.AbilityType;
+import com.gmail.nossr50.locale.LocaleLoader;
+import com.gmail.nossr50.util.Misc;
+import com.gmail.nossr50.util.Permissions;
+import com.gmail.nossr50.util.commands.CommandUtils;
+import com.gmail.nossr50.util.player.UserManager;
+import com.gmail.nossr50.util.scoreboards.ScoreboardManager;
+import com.gmail.nossr50.util.skills.SkillUtils;
+import com.google.common.collect.ImmutableList;
+
+public class MccooldownCommand implements TabExecutor {
+    @Override
+    public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
+        if (CommandUtils.noConsoleUsage(sender)) {
+            return true;
+        }
+
+        switch (args.length) {
+            case 0:
+                Player player = (Player) sender;
+
+                if (Config.getInstance().getCooldownUseBoard()) {
+                    ScoreboardManager.enablePlayerCooldownScoreboard(player);
+                    if (!Config.getInstance().getCooldownUseChat()) return true;
+                }
+
+                PlayerProfile profile = UserManager.getPlayer(player).getProfile();
+
+                player.sendMessage(LocaleLoader.getString("Commands.Cooldowns.Header"));
+                player.sendMessage(LocaleLoader.getString("mcMMO.NoSkillNote"));
+
+                for (AbilityType ability : AbilityType.NORMAL_ABILITIES) {
+                    if (!hasPermission(player, ability)) {
+                        continue;
+                    }
+
+                    int seconds = SkillUtils.calculateTimeLeft(ability, profile, player);
+
+                    if (seconds <= 0) {
+                        player.sendMessage(LocaleLoader.getString("Commands.Cooldowns.Row.Y", ability.getAbilityName()));
+                    }
+                    else {
+                        player.sendMessage(LocaleLoader.getString("Commands.Cooldowns.Row.N", ability.getAbilityName(), Integer.toString(seconds)));
+                    }
+                }
+
+                return true;
+
+            default:
+                return false;
+        }
+    }
+
+    private boolean hasPermission(Permissible permissible, AbilityType ability) {
+        switch (ability) {
+        case BERSERK:
+            return Permissions.berserk(permissible);
+        case BLAST_MINING:
+            return Permissions.remoteDetonation(permissible);
+        case BLOCK_CRACKER:
+            return Permissions.blockCracker(permissible);
+        case GIGA_DRILL_BREAKER:
+            return Permissions.gigaDrillBreaker(permissible);
+        case GREEN_TERRA:
+            return Permissions.greenTerra(permissible);
+        case LEAF_BLOWER:
+            return Permissions.leafBlower(permissible);
+        case SERRATED_STRIKES:
+            return Permissions.serratedStrikes(permissible);
+        case SKULL_SPLITTER:
+            return Permissions.skullSplitter(permissible);
+        case SUPER_BREAKER:
+            return Permissions.superBreaker(permissible);
+        case TREE_FELLER:
+            return Permissions.treeFeller(permissible);
+        default:
+            mcMMO.p.getLogger().warning("MccooldownCommand - couldn't check permission for AbilityType." + ability.name());
+            return false;
+        }
+    }
+
+    @Override
+    public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
+        return ImmutableList.of();
+    }
+}

+ 15 - 17
src/main/java/com/gmail/nossr50/commands/player/McrankCommand.java

@@ -13,13 +13,12 @@ import org.bukkit.util.StringUtil;
 import com.gmail.nossr50.mcMMO;
 import com.gmail.nossr50.config.Config;
 import com.gmail.nossr50.datatypes.player.McMMOPlayer;
+import com.gmail.nossr50.locale.LocaleLoader;
 import com.gmail.nossr50.runnables.commands.McrankCommandAsyncTask;
 import com.gmail.nossr50.util.Misc;
 import com.gmail.nossr50.util.Permissions;
 import com.gmail.nossr50.util.commands.CommandUtils;
 import com.gmail.nossr50.util.player.UserManager;
-import com.gmail.nossr50.util.scoreboards.ScoreboardManager;
-
 import com.google.common.collect.ImmutableList;
 
 public class McrankCommand implements TabExecutor {
@@ -36,13 +35,7 @@ public class McrankCommand implements TabExecutor {
                     return true;
                 }
 
-                if (Config.getInstance().getMcrankScoreboardEnabled()) {
-                    ScoreboardManager.setupPlayerScoreboard(sender.getName());
-                    ScoreboardManager.enablePlayerRankScoreboard((Player) sender);
-                }
-                else {
-                    display(sender, sender.getName());
-                }
+                display(sender, sender.getName());
 
                 return true;
 
@@ -66,13 +59,7 @@ public class McrankCommand implements TabExecutor {
                     return true;
                 }
 
-                if (sender instanceof Player && Config.getInstance().getMcrankScoreboardEnabled()) {
-                    ScoreboardManager.setupPlayerScoreboard(sender.getName());
-                    ScoreboardManager.enablePlayerRankScoreboardOthers((Player) sender, playerName);
-                }
-                else {
-                    display(sender, playerName);
-                }
+                display(sender, playerName);
                 return true;
 
             default:
@@ -92,6 +79,17 @@ public class McrankCommand implements TabExecutor {
     }
 
     private void display(CommandSender sender, String playerName) {
-        new McrankCommandAsyncTask(playerName, sender).runTaskAsynchronously(mcMMO.p);
+        if (sender instanceof Player) {
+            McMMOPlayer mcpl = UserManager.getPlayer(sender.getName());
+            if (mcpl.getDatabaseATS() + Misc.PLAYER_DATABASE_COOLDOWN_MILLIS > System.currentTimeMillis()) {
+                sender.sendMessage(LocaleLoader.getString("Commands.Database.Cooldown"));
+                return;
+            }
+            mcpl.actualizeDatabaseATS();
+        }
+
+        boolean useBoard = (sender instanceof Player) && (Config.getInstance().getRankUseBoard());
+        boolean useChat = useBoard ? Config.getInstance().getRankUseChat() : true;
+        new McrankCommandAsyncTask(playerName, sender, useBoard, useChat).runTaskAsynchronously(mcMMO.p);
     }
 }

+ 14 - 18
src/main/java/com/gmail/nossr50/commands/player/McstatsCommand.java

@@ -8,7 +8,6 @@ import org.bukkit.command.TabExecutor;
 import org.bukkit.entity.Player;
 
 import com.gmail.nossr50.config.Config;
-import com.gmail.nossr50.datatypes.player.McMMOPlayer;
 import com.gmail.nossr50.locale.LocaleLoader;
 import com.gmail.nossr50.util.commands.CommandUtils;
 import com.gmail.nossr50.util.player.UserManager;
@@ -26,28 +25,25 @@ public class McstatsCommand implements TabExecutor {
         switch (args.length) {
             case 0:
                 Player player = (Player) sender;
-                McMMOPlayer mcMMOPlayer = UserManager.getPlayer(player);
 
-                if (Config.getInstance().getMcstatsScoreboardsEnabled()) {
-                    ScoreboardManager.setupPlayerScoreboard(player.getName());
-                    ScoreboardManager.enablePlayerStatsScoreboard(mcMMOPlayer);
+                if (Config.getInstance().getStatsUseBoard()) {
+                    ScoreboardManager.enablePlayerStatsScoreboard(player);
+                    if (!Config.getInstance().getStatsUseChat()) return true;
                 }
-                else {
-                    player.sendMessage(LocaleLoader.getString("Stats.Own.Stats"));
-                    player.sendMessage(LocaleLoader.getString("mcMMO.NoSkillNote"));
+                player.sendMessage(LocaleLoader.getString("Stats.Own.Stats"));
+                player.sendMessage(LocaleLoader.getString("mcMMO.NoSkillNote"));
 
-                    CommandUtils.printGatheringSkills(player);
-                    CommandUtils.printCombatSkills(player);
-                    CommandUtils.printMiscSkills(player);
+                CommandUtils.printGatheringSkills(player);
+                CommandUtils.printCombatSkills(player);
+                CommandUtils.printMiscSkills(player);
 
-                    int powerLevelCap = Config.getInstance().getPowerLevelCap();
+                int powerLevelCap = Config.getInstance().getPowerLevelCap();
 
-                    if (powerLevelCap != Integer.MAX_VALUE) {
-                        player.sendMessage(LocaleLoader.getString("Commands.PowerLevel.Capped", UserManager.getPlayer(player).getPowerLevel(), powerLevelCap));
-                    }
-                    else {
-                        player.sendMessage(LocaleLoader.getString("Commands.PowerLevel", UserManager.getPlayer(player).getPowerLevel()));
-                    }
+                if (powerLevelCap != Integer.MAX_VALUE) {
+                    player.sendMessage(LocaleLoader.getString("Commands.PowerLevel.Capped", UserManager.getPlayer(player).getPowerLevel(), powerLevelCap));
+                }
+                else {
+                    player.sendMessage(LocaleLoader.getString("Commands.PowerLevel", UserManager.getPlayer(player).getPowerLevel()));
                 }
 
                 return true;

+ 34 - 25
src/main/java/com/gmail/nossr50/commands/player/MctopCommand.java

@@ -11,36 +11,39 @@ import org.bukkit.util.StringUtil;
 
 import com.gmail.nossr50.mcMMO;
 import com.gmail.nossr50.config.Config;
+import com.gmail.nossr50.datatypes.player.McMMOPlayer;
 import com.gmail.nossr50.datatypes.skills.SkillType;
+import com.gmail.nossr50.locale.LocaleLoader;
 import com.gmail.nossr50.runnables.commands.MctopCommandAsyncTask;
+import com.gmail.nossr50.util.Misc;
 import com.gmail.nossr50.util.Permissions;
 import com.gmail.nossr50.util.StringUtils;
 import com.gmail.nossr50.util.commands.CommandUtils;
-import com.gmail.nossr50.util.scoreboards.ScoreboardManager;
-
+import com.gmail.nossr50.util.player.UserManager;
 import com.google.common.collect.ImmutableList;
 
 public class MctopCommand implements TabExecutor {
-    private SkillType skill;
 
     @Override
     public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
+        SkillType skill;
+
         switch (args.length) {
             case 0:
-                display(1, "ALL", sender, command);
+                display(1, null, sender, command);
                 return true;
 
             case 1:
                 if (StringUtils.isInt(args[0])) {
-                    display(Math.abs(Integer.parseInt(args[0])), "ALL", sender, command);
+                    display(Math.abs(Integer.parseInt(args[0])), null, sender, command);
                     return true;
                 }
 
-                if (!extractSkill(sender, args[0])) {
+                if ((skill = extractSkill(sender, args[0])) == null) {
                     return true;
                 }
 
-                display(1, skill.toString(), sender, command);
+                display(1, skill, sender, command);
                 return true;
 
             case 2:
@@ -48,11 +51,11 @@ public class MctopCommand implements TabExecutor {
                     return true;
                 }
 
-                if (!extractSkill(sender, args[0])) {
+                if ((skill = extractSkill(sender, args[0])) == null) {
                     return true;
                 }
 
-                display(Math.abs(Integer.parseInt(args[1])), skill.toString(), sender, command);
+                display(Math.abs(Integer.parseInt(args[1])), skill, sender, command);
                 return true;
 
             default:
@@ -70,35 +73,41 @@ public class MctopCommand implements TabExecutor {
         }
     }
 
-    private void display(int page, String skill, CommandSender sender, Command command) {
-        if (!skill.equalsIgnoreCase("all") && !Permissions.mctop(sender, this.skill)) {
+    private void display(int page, SkillType skill, CommandSender sender, Command command) {
+        if (skill != null && !Permissions.mctop(sender, skill)) {
             sender.sendMessage(command.getPermissionMessage());
             return;
         }
 
-        if (sender instanceof Player && Config.getInstance().getMctopScoreboardEnabled()) {
-            ScoreboardManager.enableGlobalStatsScoreboard((Player) sender, skill, page);
-        }
-        else {
-            display(page, skill, sender);
+        if (sender instanceof Player) {
+            McMMOPlayer mcpl = UserManager.getPlayer(sender.getName());
+            if (mcpl.getDatabaseATS() + Misc.PLAYER_DATABASE_COOLDOWN_MILLIS > System.currentTimeMillis()) {
+                sender.sendMessage(LocaleLoader.getString("Commands.Database.Cooldown"));
+                return;
+            }
+            mcpl.actualizeDatabaseATS();
         }
+
+        display(page, skill, sender);
     }
 
-    private void display(int page, String query, CommandSender sender) {
-        new MctopCommandAsyncTask(page, query, sender).runTaskAsynchronously(mcMMO.p);
+    private void display(int page, SkillType skill, CommandSender sender) {
+        boolean useBoard = (sender instanceof Player) && (Config.getInstance().getTopUseBoard());
+        boolean useChat = useBoard ? Config.getInstance().getTopUseChat() : true;
+
+        new MctopCommandAsyncTask(page, skill, sender, useBoard, useChat).runTaskAsynchronously(mcMMO.p);
     }
 
-    private boolean extractSkill(CommandSender sender, String skillName) {
+    private SkillType extractSkill(CommandSender sender, String skillName) {
         if (CommandUtils.isInvalidSkill(sender, skillName)) {
-            return false;
+            return null;
         }
+        SkillType skill = SkillType.getSkill(skillName);
 
-        skill = SkillType.getSkill(skillName);
-
-        if (CommandUtils.isChildSkill(sender, skill)) {
-            return false;
+        if (skill != null && CommandUtils.isChildSkill(sender, skill)) {
+            return null;
         }
 
-        return true;
+        return skill;
     }
 }

+ 5 - 7
src/main/java/com/gmail/nossr50/commands/skills/SkillCommand.java

@@ -69,17 +69,15 @@ public abstract class SkillCommand implements TabExecutor {
                 permissionsCheck();
                 dataCalculations();
 
+                if (Config.getInstance().getSkillUseBoard()) {
+                    ScoreboardManager.enablePlayerSkillScoreboard(player, skill);
+                }
+
                 if (!skill.isChildSkill()) {
                     player.sendMessage(LocaleLoader.getString("Skills.Header", skillName));
                     player.sendMessage(LocaleLoader.getString("Commands.XPGain", LocaleLoader.getString("Commands.XPGain." + StringUtils.getCapitalized(skill.toString()))));
+                    player.sendMessage(LocaleLoader.getString("Effects.Level", (int) skillValue, profile.getSkillXpLevel(skill), profile.getXpToLevel(skill)));
 
-                    if (Config.getInstance().getSkillScoreboardEnabled()) {
-                        ScoreboardManager.setupPlayerScoreboard(player.getName());
-                        ScoreboardManager.enablePlayerSkillScoreboard(mcMMOPlayer, skill);
-                    }
-                    else {
-                        player.sendMessage(LocaleLoader.getString("Effects.Level", (int) skillValue, profile.getSkillXpLevel(skill), profile.getXpToLevel(skill)));
-                    }
                 }
                 else {
                     player.sendMessage(LocaleLoader.getString("Skills.Header", skillName + " " + LocaleLoader.getString("Skills.Child")));

+ 61 - 21
src/main/java/com/gmail/nossr50/config/Config.java

@@ -45,26 +45,52 @@ public class Config extends AutoUpdateConfigLoader {
         }
 
         /* Scoreboards */
-        if (getMcrankScoreboardTime() != -1 && getMcrankScoreboardTime() <= 0) {
-            reason.add("Scoreboards.Mcrank.Display_Time should be greater than 0 or -1!");
+        if (getRankScoreboardTime() != -1 && getRankScoreboardTime() <= 0) {
+            reason.add("Scoreboard.Types.Rank.Display_Time should be greater than 0, or -1!");
         }
 
-        if (getMcstatsScoreboardTime() != -1 && getMcstatsScoreboardTime() <= 0) {
-            reason.add("Scoreboards.Mcstats.Display_Time should be greater than 0 or -1!");
+        if (getStatsScoreboardTime() != -1 && getStatsScoreboardTime() <= 0) {
+            reason.add("Scoreboard.Types.Stats.Display_Time should be greater than 0, or -1!");
         }
 
-        if (getMctopScoreboardTime() != -1 && getMctopScoreboardTime() <= 0) {
-            reason.add("Scoreboards.Mctop.Display_Time should be greater than 0 or -1!");
+        if (getTopScoreboardTime() != -1 && getTopScoreboardTime() <= 0) {
+            reason.add("Scoreboard.Types.Top.Display_Time should be greater than 0, or -1!");
         }
 
         if (getInspectScoreboardTime() != -1 && getInspectScoreboardTime() <= 0) {
-            reason.add("Scoreboards.Inspect.Display_Time should be greater than 0 or -1!");
+            reason.add("Scoreboard.Types.Inspect.Display_Time should be greater than 0, or -1!");
         }
 
         if (getSkillScoreboardTime() != -1 && getSkillScoreboardTime() <= 0) {
-            reason.add("Scoreboards.Skillname.Display_Time should be greater than 0 or -1!");
+            reason.add("Scoreboard.Types.Skill.Display_Time should be greater than 0, or -1!");
         }
 
+        if (getSkillLevelUpTime() != -1 && getSkillScoreboardTime() <= 0) {
+            reason.add("Scoreboard.Types.Skill.Display_Time should be greater than 0, or -1!");
+        }
+
+        if (!(getRankUseChat() || getRankUseBoard())) {
+            reason.add("Either Board or Print in Scoreboard.Types.Rank must be true!");
+        }
+
+        if (!(getTopUseChat() || getTopUseBoard())) {
+            reason.add("Either Board or Print in Scoreboard.Types.Top must be true!");
+        }
+
+        if (!(getStatsUseChat() || getStatsUseBoard())) {
+            reason.add("Either Board or Print in Scoreboard.Types.Stats must be true!");
+        }
+
+        if (!(getInspectUseChat() || getInspectUseBoard())) {
+            reason.add("Either Board or Print in Scoreboard.Types.Inspect must be true!");
+        }
+
+        /* Skill.Print setting removed, as I can't think of a good use for it
+        if (!(getSkillUseChat() || getSkillUseBoard())) {
+            reason.add("Either Board or Print in Scoreboard.Commands.Skill must be true!");
+        }
+        // */
+
         /* Database Purging */
         if (getPurgeInterval() < -1) {
             reason.add("Database_Purging.Purge_Interval should be greater than, or equal to -1!");
@@ -212,22 +238,36 @@ public class Config extends AutoUpdateConfigLoader {
     public int getMobHealthbarTime() { return config.getInt("Mob_Healthbar.Display_Time", 3); }
 
     /* Scoreboards */
-    public boolean getMcrankScoreboardEnabled() { return config.getBoolean("Scoreboards.Mcrank.Use", true); }
-    public int getMcrankScoreboardTime() { return config.getInt("Scoreboards.Mcrank.Display_Time", 10); }
+    public boolean getRankUseChat() { return config.getBoolean("Scoreboard.Types.Rank.Print", false); }
+    public boolean getRankUseBoard() { return config.getBoolean("Scoreboard.Types.Rank.Board", true); }
+    public int getRankScoreboardTime() { return config.getInt("Scoreboard.Types.Rank.Display_Time", 10); }
+
+    public boolean getTopUseChat() { return config.getBoolean("Scoreboard.Types.Top.Print", true); }
+    public boolean getTopUseBoard() { return config.getBoolean("Scoreboard.Types.Top.Board", true); }
+    public int getTopScoreboardTime() { return config.getInt("Scoreboard.Types.Top.Display_Time", 15); }
+
+    public boolean getStatsUseChat() { return config.getBoolean("Scoreboard.Types.Stats.Print", true); }
+    public boolean getStatsUseBoard() { return config.getBoolean("Scoreboard.Types.Stats.Board", true); }
+    public int getStatsScoreboardTime() { return config.getInt("Scoreboard.Types.Stats.Display_Time", 10); }
 
-    public boolean getMcstatsScoreboardsEnabled() { return config.getBoolean("Scoreboards.Mcstats.Use", true); }
-    public int getMcstatsScoreboardTime() { return config.getInt("Scoreboards.Mcstats.Display_Time", 10); }
+    public boolean getInspectUseChat() { return config.getBoolean("Scoreboard.Types.Inspect.Print", true); }
+    public boolean getInspectUseBoard() { return config.getBoolean("Scoreboard.Types.Inspect.Board", true); }
+    public int getInspectScoreboardTime() { return config.getInt("Scoreboard.Types.Inspect.Display_Time", 25); }
 
-    public boolean getMctopScoreboardEnabled() { return config.getBoolean("Scoreboards.Mctop.Use", true); }
-    public int getMctopScoreboardTime() { return config.getInt("Scoreboards.Mctop.Display_Time", 10); }
+    public boolean getCooldownUseChat() { return config.getBoolean("Scoreboard.Types.Cooldown.Print", false); }
+    public boolean getCooldownUseBoard() { return config.getBoolean("Scoreboard.Types.Cooldown.Board", true); }
+    public int getCooldownScoreboardTime() { return config.getInt("Scoreboard.Types.Cooldown.Display_Time", 41); }
 
-    public boolean getInspectScoreboardEnabled() { return config.getBoolean("Scoreboards.Inspect.Use", true); }
-    public int getInspectScoreboardTime() { return config.getInt("Scoreboards.Inspect.Display_Time", 10); }
+    // public boolean getSkillUseChat() { return config.getBoolean("Scoreboard.Types.Skill.Print", false); }
+    public boolean getSkillUseBoard() { return config.getBoolean("Scoreboard.Types.Skill.Board", true); }
+    public int getSkillScoreboardTime() { return config.getInt("Scoreboard.Types.Skill.Display_Time", 30); }
+    public boolean getSkillLevelUpBoard() { return config.getBoolean("Scoreboard.Types.Skill.LevelUp_Board", true); }
+    public int getSkillLevelUpTime() { return config.getInt("Scoreboard.Types.Skill.LevelUp_Time", 5); }
 
-    public boolean getSkillScoreboardEnabled() { return config.getBoolean("Scoreboards.Skillname.Use", true); }
-    public int getSkillScoreboardTime() { return config.getInt("Scoreboards.Skillname.Display_Time", 10); }
+    public boolean getPowerLevelTagsEnabled() { return config.getBoolean("Scoreboard.Power_Level_Tags", false); }
 
-    public boolean getPowerLevelsEnabled() { return config.getBoolean("Scoreboards.Power_Level.Use", false); }
+    public boolean getAllowKeepBoard() { return config.getBoolean("Scoreboard.Allow_Keep", true); }
+    public boolean getScoreboardRainbows() { return config.getBoolean("Scoreboard.Rainbows", false); }
 
     /* Database Purging */
     public int getPurgeInterval() { return config.getInt("Database_Purging.Purge_Interval", -1); }
@@ -324,8 +364,8 @@ public class Config extends AutoUpdateConfigLoader {
     public boolean getAbilitiesEnabled() { return config.getBoolean("Abilities.Enabled", true); }
     public boolean getAbilitiesOnlyActivateWhenSneaking() { return config.getBoolean("Abilities.Activation.Only_Activate_When_Sneaking", false); }
 
-    public int getCooldown(AbilityType ability) { return config.getInt("Abilities.Cooldowns." + ability.toString()); }
-    public int getMaxLength(AbilityType ability) { return config.getInt("Abilities.Max_Seconds." + ability.toString()); }
+    public int getCooldown(AbilityType ability) { return config.getInt("Abilities.Cooldowns." + ability.getConfigString()); }
+    public int getMaxLength(AbilityType ability) { return config.getInt("Abilities.Max_Seconds." + ability.getConfigString()); }
 
     /* Durability Settings */
     public int getAbilityToolDamage() { return config.getInt("Abilities.Tools.Durability_Loss", 1); }

+ 7 - 3
src/main/java/com/gmail/nossr50/database/DatabaseManager.java

@@ -7,6 +7,7 @@ import com.gmail.nossr50.config.Config;
 import com.gmail.nossr50.datatypes.database.DatabaseType;
 import com.gmail.nossr50.datatypes.database.PlayerStat;
 import com.gmail.nossr50.datatypes.player.PlayerProfile;
+import com.gmail.nossr50.datatypes.skills.SkillType;
 
 public interface DatabaseManager {
     // One month in milliseconds
@@ -48,15 +49,18 @@ public interface DatabaseManager {
     * @param statsPerPage The number of stats per page
     * @return the requested leaderboard information
     */
-    public List<PlayerStat> readLeaderboard(String skillName, int pageNumber, int statsPerPage);
+    public List<PlayerStat> readLeaderboard(SkillType skill, int pageNumber, int statsPerPage);
 
     /**
-     * Retrieve rank info.
+     * Retrieve rank info into a HashMap from SkillType to the rank.
+     * <p>
+     * The special value <code>null</code> is used to represent the Power
+     * Level rank (the combination of all skill levels).
      *
      * @param playerName The name of the user to retrieve the rankings for
      * @return the requested rank information
      */
-    public Map<String, Integer> readRank(String playerName);
+    public Map<SkillType, Integer> readRank(String playerName);
 
     /**
      * Add a new user to the database.

+ 16 - 7
src/main/java/com/gmail/nossr50/database/FlatfileDatabaseManager.java

@@ -285,24 +285,24 @@ public final class FlatfileDatabaseManager implements DatabaseManager {
         }
     }
 
-    public List<PlayerStat> readLeaderboard(String skillName, int pageNumber, int statsPerPage) {
+    public List<PlayerStat> readLeaderboard(SkillType skill, int pageNumber, int statsPerPage) {
         updateLeaderboards();
-        List<PlayerStat> statsList = skillName.equalsIgnoreCase("all") ? powerLevels : playerStatHash.get(SkillType.getSkill(skillName));
+        List<PlayerStat> statsList = skill == null ? powerLevels : playerStatHash.get(skill);
         int fromIndex = (Math.max(pageNumber, 1) - 1) * statsPerPage;
 
         return statsList.subList(Math.min(fromIndex, statsList.size()), Math.min(fromIndex + statsPerPage, statsList.size()));
     }
 
-    public Map<String, Integer> readRank(String playerName) {
+    public Map<SkillType, Integer> readRank(String playerName) {
         updateLeaderboards();
 
-        Map<String, Integer> skills = new HashMap<String, Integer>();
+        Map<SkillType, Integer> skills = new HashMap<SkillType, Integer>();
 
         for (SkillType skill : SkillType.NON_CHILD_SKILLS) {
-            skills.put(skill.name(), getPlayerRank(playerName, playerStatHash.get(skill)));
+            skills.put(skill, getPlayerRank(playerName, playerStatHash.get(skill)));
         }
 
-        skills.put("ALL", getPlayerRank(playerName, powerLevels));
+        skills.put(null, getPlayerRank(playerName, powerLevels));
 
         return skills;
     }
@@ -400,7 +400,16 @@ public final class FlatfileDatabaseManager implements DatabaseManager {
                 e.printStackTrace();
             }
             finally {
-                tryClose(in);
+                // I have no idea why it's necessary to inline tryClose() here, but it removes
+                // a resource leak warning, and I'm trusting the compiler on this one.
+                if (in != null) {
+                    try {
+                        in.close();
+                    }
+                    catch (IOException e) {
+                        e.printStackTrace();
+                    }
+                }
             }
         }
 

+ 6 - 6
src/main/java/com/gmail/nossr50/database/SQLDatabaseManager.java

@@ -188,11 +188,11 @@ public final class SQLDatabaseManager implements DatabaseManager {
         return success;
     }
 
-    public List<PlayerStat> readLeaderboard(String skillName, int pageNumber, int statsPerPage) {
+    public List<PlayerStat> readLeaderboard(SkillType skill, int pageNumber, int statsPerPage) {
         List<PlayerStat> stats = new ArrayList<PlayerStat>();
 
         if (checkConnected()) {
-            String query = skillName.equalsIgnoreCase("ALL") ? "taming+mining+woodcutting+repair+unarmed+herbalism+excavation+archery+swords+axes+acrobatics+fishing" : skillName;
+            String query = skill == null ? "taming+mining+woodcutting+repair+unarmed+herbalism+excavation+archery+swords+axes+acrobatics+fishing" : skill.name().toLowerCase();
             ResultSet resultSet = null;
             PreparedStatement statement = null;
 
@@ -230,8 +230,8 @@ public final class SQLDatabaseManager implements DatabaseManager {
         return stats;
     }
 
-    public Map<String, Integer> readRank(String playerName) {
-        Map<String, Integer> skills = new HashMap<String, Integer>();
+    public Map<SkillType, Integer> readRank(String playerName) {
+        Map<SkillType, Integer> skills = new HashMap<SkillType, Integer>();
 
         if (checkConnected()) {
             ResultSet resultSet;
@@ -262,7 +262,7 @@ public final class SQLDatabaseManager implements DatabaseManager {
 
                     while (resultSet.next()) {
                         if (resultSet.getString("user").equalsIgnoreCase(playerName)) {
-                            skills.put(skillType.name(), rank + resultSet.getRow());
+                            skills.put(skillType, rank + resultSet.getRow());
                             break;
                         }
                     }
@@ -299,7 +299,7 @@ public final class SQLDatabaseManager implements DatabaseManager {
 
                 while (resultSet.next()) {
                     if (resultSet.getString("user").equalsIgnoreCase(playerName)) {
-                        skills.put("ALL", rank + resultSet.getRow());
+                        skills.put(null, rank + resultSet.getRow());
                         break;
                     }
                 }

+ 9 - 0
src/main/java/com/gmail/nossr50/datatypes/player/McMMOPlayer.java

@@ -97,6 +97,7 @@ public class McMMOPlayer {
     private int recentlyHurt;
     private int respawnATS;
     private int teleportATS;
+    private long databaseATS;
     private int chimeraWingLastUse;
     private Location teleportCommence;
 
@@ -427,6 +428,14 @@ public class McMMOPlayer {
         teleportATS = (int) (System.currentTimeMillis() / Misc.TIME_CONVERSION_FACTOR);
     }
 
+    public long getDatabaseATS() {
+        return databaseATS;
+    }
+
+    public void actualizeDatabaseATS() {
+        databaseATS = System.currentTimeMillis();
+    }
+
     /*
      * Repair Anvil Placement
      */

+ 67 - 1
src/main/java/com/gmail/nossr50/datatypes/skills/AbilityType.java

@@ -1,5 +1,7 @@
 package com.gmail.nossr50.datatypes.skills;
 
+import java.util.List;
+
 import org.bukkit.Material;
 import org.bukkit.block.Block;
 import org.bukkit.block.BlockState;
@@ -11,9 +13,11 @@ import com.gmail.nossr50.util.BlockUtils;
 import com.gmail.nossr50.util.EventUtils;
 import com.gmail.nossr50.util.Permissions;
 import com.gmail.nossr50.util.StringUtils;
+import com.google.common.collect.ImmutableList;
 
 public enum AbilityType {
     BERSERK(
+            "Unarmed.Skills.Berserk.Name",
             "Unarmed.Skills.Berserk.On",
             "Unarmed.Skills.Berserk.Off",
             "Unarmed.Skills.Berserk.Other.On",
@@ -21,6 +25,7 @@ public enum AbilityType {
             "Unarmed.Skills.Berserk.Other.Off"),
 
     SUPER_BREAKER(
+            "Mining.Skills.SuperBreaker.Name",
             "Mining.Skills.SuperBreaker.On",
             "Mining.Skills.SuperBreaker.Off",
             "Mining.Skills.SuperBreaker.Other.On",
@@ -28,6 +33,7 @@ public enum AbilityType {
             "Mining.Skills.SuperBreaker.Other.Off"),
 
     GIGA_DRILL_BREAKER(
+            "Excavation.Skills.GigaDrillBreaker.Name",
             "Excavation.Skills.GigaDrillBreaker.On",
             "Excavation.Skills.GigaDrillBreaker.Off",
             "Excavation.Skills.GigaDrillBreaker.Other.On",
@@ -35,6 +41,7 @@ public enum AbilityType {
             "Excavation.Skills.GigaDrillBreaker.Other.Off"),
 
     GREEN_TERRA(
+            "Herbalism.Skills.GTe.Name",
             "Herbalism.Skills.GTe.On",
             "Herbalism.Skills.GTe.Off",
             "Herbalism.Skills.GTe.Other.On",
@@ -42,6 +49,7 @@ public enum AbilityType {
             "Herbalism.Skills.GTe.Other.Off"),
 
     SKULL_SPLITTER(
+            "Axes.Skills.SS.Name",
             "Axes.Skills.SS.On",
             "Axes.Skills.SS.Off",
             "Axes.Skills.SS.Other.On",
@@ -49,6 +57,7 @@ public enum AbilityType {
             "Axes.Skills.SS.Other.Off"),
 
     TREE_FELLER(
+            "Woodcutting.Skills.TreeFeller.Name",
             "Woodcutting.Skills.TreeFeller.On",
             "Woodcutting.Skills.TreeFeller.Off",
             "Woodcutting.Skills.TreeFeller.Other.On",
@@ -56,40 +65,81 @@ public enum AbilityType {
             "Woodcutting.Skills.TreeFeller.Other.Off"),
 
     SERRATED_STRIKES(
+            "Swords.Skills.SS.Name",
             "Swords.Skills.SS.On",
             "Swords.Skills.SS.Off",
             "Swords.Skills.SS.Other.On",
             "Swords.Skills.SS.Refresh",
             "Swords.Skills.SS.Other.Off"),
 
+    /**
+     * Has cooldown - but has to share a skill with Super Breaker, so needs special treatment
+     */
     BLAST_MINING(
+            "Mining.Blast.Name",
             null,
             null,
             "Mining.Blast.Other.On",
             "Mining.Blast.Refresh",
             null),
 
+    /**
+     * No cooldown - always active
+     */
     LEAF_BLOWER(
             null,
             null,
             null,
             null,
+            null,
             null),
 
+    /**
+     * Not a first-class Ability - part of Berserk
+     */
     BLOCK_CRACKER(
             null,
             null,
             null,
             null,
+            null,
             null);
 
+    private String abilityName;
     private String abilityOn;
     private String abilityOff;
     private String abilityPlayer;
     private String abilityRefresh;
     private String abilityPlayerOff;
 
-    private AbilityType(String abilityOn, String abilityOff, String abilityPlayer, String abilityRefresh, String abilityPlayerOff) {
+    /**
+     * Those abilities that have a cooldown saved to the database.
+     */
+    public static final List<AbilityType> NORMAL_ABILITIES;
+    /**
+     * Those abilities that do not have a cooldown saved to the database.
+     */
+    public static final List<AbilityType> NON_NORMAL_ABILITIES;
+
+    static {
+        NORMAL_ABILITIES = ImmutableList.of(
+                BERSERK,
+                SUPER_BREAKER,
+                GIGA_DRILL_BREAKER,
+                GREEN_TERRA,
+                SKULL_SPLITTER,
+                TREE_FELLER,
+                SERRATED_STRIKES,
+                BLAST_MINING
+                );
+        NON_NORMAL_ABILITIES = ImmutableList.of(
+                LEAF_BLOWER,
+                BLOCK_CRACKER
+                );
+    }
+
+    private AbilityType(String abilityName, String abilityOn, String abilityOff, String abilityPlayer, String abilityRefresh, String abilityPlayerOff) {
+        this.abilityName = abilityName;
         this.abilityOn = abilityOn;
         this.abilityOff = abilityOff;
         this.abilityPlayer = abilityPlayer;
@@ -105,6 +155,17 @@ public enum AbilityType {
         return Config.getInstance().getMaxLength(this);
     }
 
+    /**
+     * May return null
+     * @return ability name, or null if unavailable
+     */
+    public String getAbilityName() {
+        if (this.abilityName == null) {
+            return null;
+        }
+        return LocaleLoader.getString(this.abilityName);
+    }
+
     public String getAbilityOn() {
         return LocaleLoader.getString(this.abilityOn);
     }
@@ -125,6 +186,11 @@ public enum AbilityType {
         return LocaleLoader.getString(this.abilityRefresh);
     }
 
+    public String getConfigString() {
+        // If toString() changes, place old code here to not break config.yml
+        return this.toString();
+    }
+
     @Override
     public String toString() {
         String baseString = name();

+ 0 - 2
src/main/java/com/gmail/nossr50/listeners/PlayerListener.java

@@ -57,7 +57,6 @@ import com.gmail.nossr50.util.MobHealthbarUtils;
 import com.gmail.nossr50.util.Motd;
 import com.gmail.nossr50.util.Permissions;
 import com.gmail.nossr50.util.player.UserManager;
-import com.gmail.nossr50.util.scoreboards.ScoreboardManager;
 import com.gmail.nossr50.util.skills.SkillUtils;
 
 public class PlayerListener implements Listener {
@@ -363,7 +362,6 @@ public class PlayerListener implements Listener {
         }
 
         UserManager.addUser(player).actualizeRespawnATS();
-        ScoreboardManager.enablePowerLevelDisplay(player);
 
         if (Config.getInstance().getMOTDEnabled() && Permissions.motd(player)) {
             Motd.displayAll(player);

+ 49 - 0
src/main/java/com/gmail/nossr50/listeners/ScoreboardsListener.java

@@ -0,0 +1,49 @@
+package com.gmail.nossr50.listeners;
+
+import org.bukkit.event.EventHandler;
+import org.bukkit.event.EventPriority;
+import org.bukkit.event.Listener;
+import org.bukkit.event.player.PlayerJoinEvent;
+import org.bukkit.event.player.PlayerQuitEvent;
+
+import com.gmail.nossr50.mcMMO;
+import com.gmail.nossr50.events.experience.McMMOPlayerLevelUpEvent;
+import com.gmail.nossr50.events.experience.McMMOPlayerXpGainEvent;
+import com.gmail.nossr50.events.skills.abilities.McMMOPlayerAbilityActivateEvent;
+import com.gmail.nossr50.util.Misc;
+import com.gmail.nossr50.util.player.UserManager;
+import com.gmail.nossr50.util.scoreboards.ScoreboardManager;
+import com.gmail.nossr50.util.skills.SkillUtils;
+
+public class ScoreboardsListener implements Listener {
+    private final mcMMO plugin;
+
+    public ScoreboardsListener(final mcMMO plugin) {
+        this.plugin = plugin;
+    }
+
+    @EventHandler
+    public void onPlayerJoin(PlayerJoinEvent e) {
+        ScoreboardManager.setupPlayer(e.getPlayer());
+    }
+
+    @EventHandler
+    public void onPlayerQuit(PlayerQuitEvent e) {
+        ScoreboardManager.teardownPlayer(e.getPlayer());
+    }
+
+    @EventHandler(priority = EventPriority.MONITOR)
+    public void onPlayerLevelUp(McMMOPlayerLevelUpEvent e) {
+        ScoreboardManager.handleLevelUp(e.getPlayer(), e.getSkill());
+    }
+
+    @EventHandler(priority = EventPriority.MONITOR)
+    public void onPlayerXp(McMMOPlayerXpGainEvent e) {
+        ScoreboardManager.handleXp(e.getPlayer(), e.getSkill());
+    }
+
+    @EventHandler(priority = EventPriority.MONITOR)
+    public void onAbility(McMMOPlayerAbilityActivateEvent e) {
+        ScoreboardManager.cooldownUpdate(e.getPlayer(), e.getSkill(), SkillUtils.calculateTimeLeft(UserManager.getPlayer(e.getPlayer()).getProfile().getSkillDATS(e.getAbility()) * Misc.TIME_CONVERSION_FACTOR, e.getAbility().getCooldown(), e.getPlayer()));
+    }
+}

+ 2 - 0
src/main/java/com/gmail/nossr50/locale/LocaleLoader.java

@@ -7,6 +7,7 @@ import java.util.ResourceBundle;
 
 import org.bukkit.ChatColor;
 
+import com.gmail.nossr50.mcMMO;
 import com.gmail.nossr50.config.Config;
 
 public final class LocaleLoader {
@@ -40,6 +41,7 @@ public final class LocaleLoader {
                 return getString(key, enBundle, messageArguments);
             }
             catch (MissingResourceException ex2) {
+                mcMMO.p.getLogger().warning("Could not find locale string: " + key);
                 return '!' + key + '!';
             }
         }

+ 9 - 0
src/main/java/com/gmail/nossr50/mcMMO.java

@@ -25,6 +25,7 @@ import com.gmail.nossr50.listeners.BlockListener;
 import com.gmail.nossr50.listeners.EntityListener;
 import com.gmail.nossr50.listeners.InventoryListener;
 import com.gmail.nossr50.listeners.PlayerListener;
+import com.gmail.nossr50.listeners.ScoreboardsListener;
 import com.gmail.nossr50.listeners.SelfListener;
 import com.gmail.nossr50.listeners.WorldListener;
 import com.gmail.nossr50.locale.LocaleLoader;
@@ -33,6 +34,7 @@ import com.gmail.nossr50.party.PartyManager;
 import com.gmail.nossr50.runnables.SaveTimerTask;
 import com.gmail.nossr50.runnables.database.UserPurgeTask;
 import com.gmail.nossr50.runnables.party.PartyAutoKickTask;
+import com.gmail.nossr50.runnables.player.PowerLevelUpdatingTask;
 import com.gmail.nossr50.runnables.skills.BleedTimerTask;
 import com.gmail.nossr50.skills.child.ChildConfig;
 import com.gmail.nossr50.skills.repair.config.RepairConfigManager;
@@ -48,6 +50,7 @@ import com.gmail.nossr50.util.blockmeta.chunkmeta.ChunkManagerFactory;
 import com.gmail.nossr50.util.commands.CommandRegistrationManager;
 import com.gmail.nossr50.util.experience.FormulaManager;
 import com.gmail.nossr50.util.player.UserManager;
+import com.gmail.nossr50.util.scoreboards.ScoreboardManager;
 
 import net.gravitydevelopment.updater.mcmmo.Updater;
 import net.gravitydevelopment.updater.mcmmo.Updater.UpdateResult;
@@ -152,6 +155,7 @@ public class mcMMO extends JavaPlugin {
 
             for (Player player : getServer().getOnlinePlayers()) {
                 UserManager.addUser(player); // In case of reload add all users back into UserManager
+                ScoreboardManager.setupPlayer(player);
             }
 
             debug("Version " + getDescription().getVersion() + " is enabled!");
@@ -191,6 +195,7 @@ public class mcMMO extends JavaPlugin {
         try {
             UserManager.saveAll();      // Make sure to save player information if the server shuts down
             PartyManager.saveParties(); // Save our parties
+            ScoreboardManager.teardownAll();
             formulaManager.saveFormula();
             placeStore.saveAll();       // Save our metadata
             placeStore.cleanUp();       // Cleanup empty metadata stores
@@ -379,6 +384,7 @@ public class mcMMO extends JavaPlugin {
         pluginManager.registerEvents(new EntityListener(this), this);
         pluginManager.registerEvents(new InventoryListener(this), this);
         pluginManager.registerEvents(new SelfListener(), this);
+        pluginManager.registerEvents(new ScoreboardsListener(this), this);
         pluginManager.registerEvents(new WorldListener(this), this);
     }
 
@@ -415,6 +421,9 @@ public class mcMMO extends JavaPlugin {
         else if (kickIntervalTicks > 0) {
             new PartyAutoKickTask().runTaskTimer(this, kickIntervalTicks, kickIntervalTicks);
         }
+
+        // Update power level tag scoreboards
+        new PowerLevelUpdatingTask().runTaskTimer(this, 2 * Misc.TICK_CONVERSION_FACTOR, 2 * Misc.TICK_CONVERSION_FACTOR);
     }
 
     private void checkModConfigs() {

+ 12 - 3
src/main/java/com/gmail/nossr50/runnables/commands/McrankCommandAsyncTask.java

@@ -2,25 +2,34 @@ package com.gmail.nossr50.runnables.commands;
 
 import java.util.Map;
 
+import org.apache.commons.lang.Validate;
 import org.bukkit.command.CommandSender;
+import org.bukkit.entity.Player;
 import org.bukkit.scheduler.BukkitRunnable;
 
 import com.gmail.nossr50.mcMMO;
+import com.gmail.nossr50.datatypes.skills.SkillType;
 
 public class McrankCommandAsyncTask extends BukkitRunnable {
     private final String playerName;
     private final CommandSender sender;
+    private final boolean useBoard, useChat;
 
-    public McrankCommandAsyncTask(String playerName, CommandSender sender) {
+    public McrankCommandAsyncTask(String playerName, CommandSender sender, boolean useBoard, boolean useChat) {
+        Validate.isTrue(useBoard || useChat, "Attempted to start a rank retrieval with both board and chat off");
+        Validate.notNull(sender, "Attempted to start a rank retrieval with no recipient");
+        if (useBoard) Validate.isTrue(sender instanceof Player, "Attempted to start a rank retrieval displaying scoreboard to a non-player");
         this.playerName = playerName;
         this.sender = sender;
+        this.useBoard = useBoard;
+        this.useChat = useChat;
     }
 
     @Override
     public void run() {
-        Map<String, Integer> skills = mcMMO.getDatabaseManager().readRank(playerName);
+        Map<SkillType, Integer> skills = mcMMO.getDatabaseManager().readRank(playerName);
 
-        new McrankCommandDisplayTask(skills, sender, playerName).runTaskLater(mcMMO.p, 1);
+        new McrankCommandDisplayTask(skills, sender, playerName, useBoard, useChat).runTaskLater(mcMMO.p, 1);
     }
 }
 

+ 30 - 4
src/main/java/com/gmail/nossr50/runnables/commands/McrankCommandDisplayTask.java

@@ -10,20 +10,37 @@ import com.gmail.nossr50.mcMMO;
 import com.gmail.nossr50.datatypes.skills.SkillType;
 import com.gmail.nossr50.locale.LocaleLoader;
 import com.gmail.nossr50.util.Permissions;
+import com.gmail.nossr50.util.scoreboards.ScoreboardManager;
+import com.gmail.nossr50.util.skills.SkillUtils;
 
+/**
+ * Display the results of McrankCommandAsyncTask to the sender.
+ */
 public class McrankCommandDisplayTask extends BukkitRunnable {
-    private final Map<String, Integer> skills;
+    private final Map<SkillType, Integer> skills;
     private final CommandSender sender;
     private final String playerName;
+    private final boolean useBoard, useChat;
 
-    public McrankCommandDisplayTask(Map<String, Integer> skills, CommandSender sender, String playerName) {
+    /*package-private*/ McrankCommandDisplayTask(Map<SkillType, Integer> skills, CommandSender sender, String playerName, boolean useBoard, boolean useChat) {
         this.skills = skills;
         this.sender = sender;
         this.playerName = playerName;
+        this.useBoard = useBoard;
+        this.useChat = useChat;
     }
 
     @Override
     public void run() {
+        if (useBoard) {
+            displayBoard();
+        }
+        if (useChat){
+            displayChat();
+        }
+    }
+
+    private void displayChat() {
         Player player = mcMMO.p.getServer().getPlayerExact(playerName);
         Integer rank;
 
@@ -35,11 +52,20 @@ public class McrankCommandDisplayTask extends BukkitRunnable {
                 continue;
             }
 
-            rank = skills.get(skill.name());
+            rank = skills.get(skill);
             sender.sendMessage(LocaleLoader.getString("Commands.mcrank.Skill", skill.getSkillName(), (rank == null ? LocaleLoader.getString("Commands.mcrank.Unranked") : rank)));
         }
 
-        rank = skills.get("ALL");
+        rank = skills.get(null);
         sender.sendMessage(LocaleLoader.getString("Commands.mcrank.Overall", (rank == null ? LocaleLoader.getString("Commands.mcrank.Unranked") : rank)));
     }
+
+    public void displayBoard() {
+        if (playerName == null || sender.getName().equalsIgnoreCase(playerName)) {
+            ScoreboardManager.showPlayerRankScoreboard((Player) sender, skills);
+        }
+        else {
+            ScoreboardManager.showPlayerRankScoreboardOthers((Player) sender, playerName, skills);
+        }
+    }
 }

+ 14 - 5
src/main/java/com/gmail/nossr50/runnables/commands/MctopCommandAsyncTask.java

@@ -2,27 +2,36 @@ package com.gmail.nossr50.runnables.commands;
 
 import java.util.List;
 
+import org.apache.commons.lang.Validate;
 import org.bukkit.command.CommandSender;
+import org.bukkit.entity.Player;
 import org.bukkit.scheduler.BukkitRunnable;
 
 import com.gmail.nossr50.mcMMO;
 import com.gmail.nossr50.datatypes.database.PlayerStat;
+import com.gmail.nossr50.datatypes.skills.SkillType;
 
 public class MctopCommandAsyncTask extends BukkitRunnable {
-    private CommandSender sender;
-    private String skill;
-    private int page;
+    private final CommandSender sender;
+    private final SkillType skill;
+    private final int page;
+    private final boolean useBoard, useChat;
 
-    public MctopCommandAsyncTask(int page, String skill, CommandSender sender) {
+    public MctopCommandAsyncTask(int page, SkillType skill, CommandSender sender, boolean useBoard, boolean useChat) {
+        Validate.isTrue(useBoard || useChat, "Attempted to start a rank retrieval with both board and chat off");
+        Validate.notNull(sender, "Attempted to start a rank retrieval with no recipient");
+        if (useBoard) Validate.isTrue(sender instanceof Player, "Attempted to start a rank retrieval displaying scoreboard to a non-player");
         this.page = page;
         this.skill = skill;
         this.sender = sender;
+        this.useBoard = useBoard;
+        this.useChat = useChat;
     }
 
     @Override
     public void run() {
         final List<PlayerStat> userStats = mcMMO.getDatabaseManager().readLeaderboard(skill, page, 10);
 
-        new MctopCommandDisplayTask(userStats, page, skill, sender).runTaskLater(mcMMO.p, 1);
+        new MctopCommandDisplayTask(userStats, page, skill, sender, useBoard, useChat).runTaskLater(mcMMO.p, 1);
     }
 }

+ 39 - 13
src/main/java/com/gmail/nossr50/runnables/commands/MctopCommandDisplayTask.java

@@ -4,44 +4,70 @@ import java.util.List;
 
 import org.bukkit.ChatColor;
 import org.bukkit.command.CommandSender;
+import org.bukkit.entity.Player;
 import org.bukkit.scheduler.BukkitRunnable;
 
 import com.gmail.nossr50.datatypes.database.PlayerStat;
+import com.gmail.nossr50.datatypes.skills.SkillType;
 import com.gmail.nossr50.locale.LocaleLoader;
-import com.gmail.nossr50.util.StringUtils;
+import com.gmail.nossr50.util.scoreboards.ScoreboardManager;
+import com.gmail.nossr50.util.skills.SkillUtils;
 
+/**
+ * Display the results of {@link MctopCommandAsyncTask} to the sender.
+ */
 public class MctopCommandDisplayTask extends BukkitRunnable {
-    private List<PlayerStat> userStats;
-    private CommandSender sender;
-    private String skill;
-    private int page;
+    private final List<PlayerStat> userStats;
+    private final CommandSender sender;
+    private final SkillType skill;
+    private final int page;
+    private final boolean useBoard, useChat;
 
-    public MctopCommandDisplayTask(List<PlayerStat> userStats, int page, String skill, CommandSender sender) {
+    /*package-private*/ MctopCommandDisplayTask(List<PlayerStat> userStats, int page, SkillType skill, CommandSender sender, boolean useBoard, boolean useChat) {
         this.userStats = userStats;
         this.page = page;
         this.skill = skill;
         this.sender = sender;
+        this.useBoard = useBoard;
+        this.useChat = useChat;
     }
 
     @Override
     public void run() {
-        if (skill.equalsIgnoreCase("all")) {
+        if (useBoard) {
+            displayBoard();
+        }
+        if (useChat) {
+            displayChat();
+        }
+        sender.sendMessage(LocaleLoader.getString("Commands.mctop.Tip"));
+    }
+
+    private void displayChat() {
+        if (skill == null) {
             sender.sendMessage(LocaleLoader.getString("Commands.PowerLevel.Leaderboard"));
         }
         else {
-            sender.sendMessage(LocaleLoader.getString("Commands.Skill.Leaderboard", StringUtils.getCapitalized(skill)));
+            sender.sendMessage(LocaleLoader.getString("Commands.Skill.Leaderboard", skill.getSkillName()));
         }
 
         int place = (page * 10) - 9;
 
         for (PlayerStat stat : userStats) {
-            String digit = ((place < 10) ? "0" : "") + String.valueOf(place);
-
-            // Format: 1. Playername - skill value
-            sender.sendMessage(digit + ". " + ChatColor.GREEN + stat.name + " - " + ChatColor.WHITE + stat.statVal);
+            // Format:
+            // 01. Playername - skill value
+            // 12. Playername - skill value
+            sender.sendMessage(String.format("%2d. %s%s - %s%s", place, ChatColor.GREEN, stat.name, ChatColor.WHITE, stat.statVal));
             place++;
         }
+    }
 
-        sender.sendMessage(LocaleLoader.getString("Commands.mctop.Tip"));
+    private void displayBoard() {
+        if (skill == null) {
+            ScoreboardManager.showTopPowerScoreboard((Player) sender, page, userStats);
+        }
+        else {
+            ScoreboardManager.showTopScoreboard((Player) sender, skill, page, userStats);
+        }
     }
 }

+ 14 - 0
src/main/java/com/gmail/nossr50/runnables/player/PowerLevelUpdatingTask.java

@@ -0,0 +1,14 @@
+package com.gmail.nossr50.runnables.player;
+
+import org.bukkit.scheduler.BukkitRunnable;
+
+import com.gmail.nossr50.util.scoreboards.ScoreboardManager;
+
+public class PowerLevelUpdatingTask extends BukkitRunnable {
+    @Override
+    public void run() {
+        if (!ScoreboardManager.powerLevelHeartbeat()) {
+            this.cancel();
+        }
+    }
+}

+ 0 - 27
src/main/java/com/gmail/nossr50/runnables/scoreboards/ScoreboardChangeTask.java

@@ -1,27 +0,0 @@
-package com.gmail.nossr50.runnables.scoreboards;
-
-import org.bukkit.entity.Player;
-import org.bukkit.scheduler.BukkitRunnable;
-import org.bukkit.scoreboard.Scoreboard;
-
-import com.gmail.nossr50.util.scoreboards.ScoreboardManager;
-
-public class ScoreboardChangeTask extends BukkitRunnable {
-    private Player player;
-    private Scoreboard oldScoreboard;
-
-    public ScoreboardChangeTask(Player player, Scoreboard oldScoreboard) {
-        this.player = player;
-        this.oldScoreboard = oldScoreboard;
-    }
-
-    @Override
-    public void run() {
-        if (player.isOnline()) {
-            player.setScoreboard(oldScoreboard);
-            ScoreboardManager.enablePowerLevelDisplay(player);
-        }
-
-        ScoreboardManager.clearPendingTask(player.getName());
-    }
-}

+ 1 - 0
src/main/java/com/gmail/nossr50/util/Misc.java

@@ -33,6 +33,7 @@ public final class Misc {
     public static final int TIME_CONVERSION_FACTOR = 1000;
     public static final int TICK_CONVERSION_FACTOR = 20;
 
+    public static final long PLAYER_DATABASE_COOLDOWN_MILLIS = 1750;
     public static final int PLAYER_RESPAWN_COOLDOWN_SECONDS = 5;
     public static final double SKILL_MESSAGE_MAX_SENDING_DISTANCE = 10.0;
 

+ 12 - 2
src/main/java/com/gmail/nossr50/util/commands/CommandRegistrationManager.java

@@ -30,6 +30,7 @@ import com.gmail.nossr50.commands.hardcore.VampirismCommand;
 import com.gmail.nossr50.commands.party.PartyCommand;
 import com.gmail.nossr50.commands.party.teleport.PtpCommand;
 import com.gmail.nossr50.commands.player.InspectCommand;
+import com.gmail.nossr50.commands.player.MccooldownCommand;
 import com.gmail.nossr50.commands.player.McrankCommand;
 import com.gmail.nossr50.commands.player.McstatsCommand;
 import com.gmail.nossr50.commands.player.MctopCommand;
@@ -206,6 +207,15 @@ public final class CommandRegistrationManager {
         command.setExecutor(new InspectCommand());
     }
 
+    private static void registerMccooldownCommand() {
+        PluginCommand command = mcMMO.p.getCommand("mccooldown");
+        command.setDescription(LocaleLoader.getString("Commands.Description.mccooldown"));
+        command.setPermission("mcmmo.commands.mccooldown");
+        command.setPermissionMessage(permissionsMessage);
+        command.setUsage(LocaleLoader.getString("Commands.Usage.0", "mccooldowns"));
+        command.setExecutor(new MccooldownCommand());
+    }
+
     private static void registerMcabilityCommand() {
         PluginCommand command = mcMMO.p.getCommand("mcability");
         command.setDescription(LocaleLoader.getString("Commands.Description.mcability"));
@@ -375,8 +385,7 @@ public final class CommandRegistrationManager {
         command.setDescription("Change the current mcMMO scoreboard being displayed"); //TODO: Localize
         command.setPermission("mcmmo.commands.mcscoreboard");
         command.setPermissionMessage(permissionsMessage);
-        command.setUsage(LocaleLoader.getString("Commands.Usage.1", "mcscoreboard", "<CLEAR | RANK | STATS | TOP>"));
-        command.setUsage(command.getUsage() + "\n" + LocaleLoader.getString("Commands.Usage.3", "mcscoreboard", "top", "[" + LocaleLoader.getString("Commands.Usage.Skill") + "]", "[" + LocaleLoader.getString("Commands.Usage.Page") + "]"));
+        command.setUsage(LocaleLoader.getString("Commands.Usage.1", "mcscoreboard", "<CLEAR | KEEP | TIME>"));
         command.setExecutor(new McscoreboardCommand());
     }
 
@@ -427,6 +436,7 @@ public final class CommandRegistrationManager {
 
         // Player Commands
         registerInspectCommand();
+        registerMccooldownCommand();
         registerMcrankCommand();
         registerMcstatsCommand();
         registerMctopCommand();

+ 4 - 4
src/main/java/com/gmail/nossr50/util/commands/CommandUtils.java

@@ -3,6 +3,7 @@ package com.gmail.nossr50.util.commands;
 import java.util.ArrayList;
 import java.util.List;
 
+import org.bukkit.Bukkit;
 import org.bukkit.OfflinePlayer;
 import org.bukkit.command.CommandSender;
 import org.bukkit.entity.Player;
@@ -97,10 +98,9 @@ public final class CommandUtils {
             return true;
         }
 
-        PlayerProfile playerProfile = new PlayerProfile(playerName, false);
-
-        if (unloadedProfile(sender, playerProfile)) {
-            return false;
+        OfflinePlayer player = Bukkit.getOfflinePlayer(playerName);
+        if (!player.hasPlayedBefore()) {
+            sender.sendMessage(LocaleLoader.getString("Commands.DoesNotExist"));
         }
 
         sender.sendMessage(LocaleLoader.getString("Commands.DoesNotExist"));

+ 278 - 219
src/main/java/com/gmail/nossr50/util/scoreboards/ScoreboardManager.java

@@ -1,328 +1,387 @@
 package com.gmail.nossr50.util.scoreboards;
 
 import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Random;
 
+import org.bukkit.Bukkit;
 import org.bukkit.ChatColor;
-import org.bukkit.Server;
+import org.bukkit.OfflinePlayer;
 import org.bukkit.entity.Player;
 import org.bukkit.scoreboard.DisplaySlot;
 import org.bukkit.scoreboard.Objective;
-import org.bukkit.scoreboard.Scoreboard;
 
 import com.gmail.nossr50.mcMMO;
 import com.gmail.nossr50.config.Config;
 import com.gmail.nossr50.datatypes.database.PlayerStat;
 import com.gmail.nossr50.datatypes.player.McMMOPlayer;
 import com.gmail.nossr50.datatypes.player.PlayerProfile;
+import com.gmail.nossr50.datatypes.skills.AbilityType;
 import com.gmail.nossr50.datatypes.skills.SkillType;
 import com.gmail.nossr50.locale.LocaleLoader;
-import com.gmail.nossr50.runnables.scoreboards.ScoreboardChangeTask;
 import com.gmail.nossr50.util.Misc;
-import com.gmail.nossr50.util.Permissions;
 import com.gmail.nossr50.util.player.UserManager;
+import com.google.common.collect.ImmutableMap;
 
 public class ScoreboardManager {
-    private static final Map<String, Scoreboard> PLAYER_SCOREBOARDS = new HashMap<String, Scoreboard>();
-    private static final Scoreboard GLOBAL_STATS_SCOREBOARD = mcMMO.p.getServer().getScoreboardManager().getNewScoreboard();
-
-    private final static String PLAYER_STATS_HEADER   = LocaleLoader.getString("Scoreboard.Header.PlayerStats");
-    private final static String PLAYER_RANK_HEADER    = LocaleLoader.getString("Scoreboard.Header.PlayerRank");
-    private final static String PLAYER_INSPECT_HEADER = LocaleLoader.getString("Scoreboard.Header.PlayerInspect");
-    private final static String POWER_LEVEL_HEADER    = LocaleLoader.getString("Scoreboard.Header.PowerLevel");
-
-    private final static String POWER_LEVEL  = LocaleLoader.getString("Scoreboard.Misc.PowerLevel");
-    private final static String LEVEL        = LocaleLoader.getString("Scoreboard.Misc.Level");
-    private final static String CURRENT_XP   = LocaleLoader.getString("Scoreboard.Misc.CurrentXP");
-    private final static String REMAINING_XP = LocaleLoader.getString("Scoreboard.Misc.RemainingXP");
-    private final static String OVERALL      = LocaleLoader.getString("Scoreboard.Misc.Overall");
-
-    private final static List<String> SCOREBOARD_TASKS = new ArrayList<String>();
-
-    public static void setupPlayerScoreboard(String playerName) {
-        if (PLAYER_SCOREBOARDS.containsKey(playerName)) {
-            return;
-        }
-
-        PLAYER_SCOREBOARDS.put(playerName, mcMMO.p.getServer().getScoreboardManager().getNewScoreboard());
-    }
-
-    public static void enablePowerLevelDisplay(Player player) {
-        if (!Config.getInstance().getPowerLevelsEnabled()) {
-            return;
-        }
-
-        Scoreboard scoreboard = player.getScoreboard();
-        Objective objective;
-
-        if (scoreboard.getObjective(DisplaySlot.BELOW_NAME) == null) {
-            objective = scoreboard.registerNewObjective(POWER_LEVEL_HEADER.substring(0, Math.min(POWER_LEVEL_HEADER.length(), 16)), "dummy");
-
-            objective.getScore(player).setScore(UserManager.getPlayer(player).getPowerLevel());
-            objective.setDisplaySlot(DisplaySlot.BELOW_NAME);
+    static final Map<String, ScoreboardWrapper> PLAYER_SCOREBOARDS = new HashMap<String, ScoreboardWrapper>();
+
+    // do not localize; these are internal identifiers
+    static final String SIDEBAR_OBJECTIVE = "mcmmo_sidebar";
+    static final String POWER_OBJECTIVE = "mcmmo_pwrlvl";
+
+    static final String HEADER_STATS = LocaleLoader.getString("Scoreboard.Header.PlayerStats");
+    static final String HEADER_COOLDOWNS = LocaleLoader.getString("Scoreboard.Header.PlayerCooldowns");
+    static final String HEADER_RANK = LocaleLoader.getString("Scoreboard.Header.PlayerRank");
+    static final String TAG_POWER_LEVEL = LocaleLoader.getString("Scoreboard.Header.PowerLevel");
+
+    static final String POWER_LEVEL = LocaleLoader.getString("Scoreboard.Misc.PowerLevel");
+
+    static final OfflinePlayer LABEL_POWER_LEVEL = getOfflinePlayer(POWER_LEVEL);
+    static final OfflinePlayer LABEL_LEVEL = getOfflinePlayer(LocaleLoader.getString("Scoreboard.Misc.Level"));
+    static final OfflinePlayer LABEL_CURRENT_XP = getOfflinePlayer(LocaleLoader.getString("Scoreboard.Misc.CurrentXP"));
+    static final OfflinePlayer LABEL_REMAINING_XP = getOfflinePlayer(LocaleLoader.getString("Scoreboard.Misc.RemainingXP"));
+    static final OfflinePlayer LABEL_ABILITY_COOLDOWN = getOfflinePlayer(LocaleLoader.getString("Scoreboard.Misc.Cooldown"));
+    static final OfflinePlayer LABEL_OVERALL = getOfflinePlayer(LocaleLoader.getString("Scoreboard.Misc.Overall"));
+
+    static final Map<SkillType, OfflinePlayer> skillLabels;
+    static final Map<AbilityType, OfflinePlayer> abilityLabelsColored;
+    static final Map<AbilityType, OfflinePlayer> abilityLabelsSkill;
+    static {
+        ImmutableMap.Builder<SkillType, OfflinePlayer> b = ImmutableMap.builder();
+        ImmutableMap.Builder<AbilityType, OfflinePlayer> c = ImmutableMap.builder();
+        ImmutableMap.Builder<AbilityType, OfflinePlayer> d = ImmutableMap.builder();
+        if (Config.getInstance().getScoreboardRainbows()) {
+            Random shuffler = new Random(Bukkit.getWorlds().get(0).getSeed());
+            List<ChatColor> colors = Arrays.asList(
+                    ChatColor.WHITE,
+                    ChatColor.YELLOW,
+                    ChatColor.LIGHT_PURPLE,
+                    ChatColor.RED,
+                    ChatColor.AQUA,
+                    ChatColor.GREEN,
+                    ChatColor.DARK_GRAY,
+                    ChatColor.BLUE,
+                    ChatColor.DARK_PURPLE,
+                    ChatColor.DARK_RED,
+                    ChatColor.DARK_AQUA,
+                    ChatColor.DARK_GREEN,
+                    ChatColor.DARK_BLUE
+            );
+            Collections.shuffle(colors, shuffler);
+            int i = 0;
+            for (SkillType type : SkillType.values()) {
+                // Include child skills
+                b.put(type, getOfflinePlayer(colors.get(i) + type.getSkillName()));
+                if (type.getAbility() != null) {
+                    // the toString is the properly formatted verison for abilities
+                    c.put(type.getAbility(), getOfflinePlayer(colors.get(i) + type.getAbility().getAbilityName()));
+                    if (type == SkillType.MINING) {
+                        c.put(AbilityType.BLAST_MINING, getOfflinePlayer(colors.get(i) + AbilityType.BLAST_MINING.getAbilityName()));
+                    }
+                }
+                if (++i == colors.size()) i = 0;
+            }
         }
         else {
-            objective = scoreboard.getObjective(POWER_LEVEL_HEADER.substring(0, Math.min(POWER_LEVEL_HEADER.length(), 16)));
+            for (SkillType type : SkillType.values()) {
+                // Include child skills
+                b.put(type, getOfflinePlayer(ChatColor.GREEN + type.getSkillName()));
+                if (type.getAbility() != null) {
+                    // the toString is the properly formatted verison for abilities
+                    c.put(type.getAbility(), getOfflinePlayerDots(ChatColor.AQUA + type.getAbility().getAbilityName()));
+                    if (type == SkillType.MINING) {
+                        c.put(AbilityType.BLAST_MINING, getOfflinePlayerDots(ChatColor.AQUA + AbilityType.BLAST_MINING.getAbilityName()));
+                    }
+                }
+            }
+        }
 
-            if (objective != null) {
-                objective.getScore(player).setScore(UserManager.getPlayer(player).getPowerLevel());
+        for (AbilityType type : AbilityType.NORMAL_ABILITIES) {
+            if (type == AbilityType.BLAST_MINING) {
+                // Special-case: get a different color
+                d.put(AbilityType.BLAST_MINING, getOfflinePlayerDots(ChatColor.BLUE + AbilityType.BLAST_MINING.getAbilityName()));
             }
             else {
-                mcMMO.p.debug("Another plugin is using this scoreboard slot, so power levels cannot be enabled."); //TODO: Locale
+                d.put(type, getOfflinePlayerDots(ChatColor.AQUA + type.getAbilityName()));
             }
         }
+
+        skillLabels = b.build();
+        abilityLabelsColored = c.build();
+        abilityLabelsSkill = d.build();
     }
 
-    public static void enablePlayerSkillScoreboard(McMMOPlayer mcMMOPlayer, SkillType skill) {
-        Player player = mcMMOPlayer.getPlayer();
-        Scoreboard oldScoreboard = player.getScoreboard();
-        Scoreboard newScoreboard = PLAYER_SCOREBOARDS.get(player.getName());
-        Objective objective = newScoreboard.getObjective(skill.getSkillName());
+    private static List<String> dirtyPowerLevels = new ArrayList<String>();
 
-        if (objective == null) {
-            objective = newScoreboard.registerNewObjective(skill.getSkillName(), "dummy");
+    private static OfflinePlayer getOfflinePlayer(String name) {
+        if (name.length() > 16) {
+            name = name.substring(0, 16);
         }
+        return Bukkit.getOfflinePlayer(name);
+    }
 
-        updatePlayerSkillScores(mcMMOPlayer.getProfile(), skill, objective);
-        changeScoreboard(player, oldScoreboard, newScoreboard, Config.getInstance().getSkillScoreboardTime());
+    private static OfflinePlayer getOfflinePlayerDots(String name) {
+        if (name.length() > 16) {
+            name = name.substring(0, 16 - 2) + "..";
+        }
+        return Bukkit.getOfflinePlayer(name);
     }
 
-    public static void enablePlayerStatsScoreboard(McMMOPlayer mcMMOPlayer) {
-        Player player = mcMMOPlayer.getPlayer();
-        Scoreboard oldScoreboard = player.getScoreboard();
-        Scoreboard newScoreboard = PLAYER_SCOREBOARDS.get(player.getName());
-        Objective objective = newScoreboard.getObjective(PLAYER_STATS_HEADER.substring(0, Math.min(PLAYER_STATS_HEADER.length(), 16)));
+    public enum SidebarType {
+        NONE,
+        SKILL_BOARD,
+        STATS_BOARD,
+        COOLDOWNS_BOARD,
+        RANK_BOARD,
+        TOP_BOARD;
+    }
 
-        if (objective == null) {
-            objective = newScoreboard.registerNewObjective(PLAYER_STATS_HEADER.substring(0, Math.min(PLAYER_STATS_HEADER.length(), 16)), "dummy");
-        }
+    // **** Listener call-ins **** //
 
-        updatePlayerStatsScores(mcMMOPlayer, objective);
-        changeScoreboard(player, oldScoreboard, newScoreboard, Config.getInstance().getMcstatsScoreboardTime());
+    // Called by PlayerJoinEvent listener
+    public static void setupPlayer(Player p) {
+        PLAYER_SCOREBOARDS.put(p.getName(), ScoreboardWrapper.create(p));
+        dirtyPowerLevels.add(p.getName());
     }
 
-    public static void enablePlayerRankScoreboard(Player player) {
-        Scoreboard oldScoreboard = player.getScoreboard();
-        Scoreboard newScoreboard = PLAYER_SCOREBOARDS.get(player.getName());
-        Objective objective = newScoreboard.getObjective(PLAYER_RANK_HEADER.substring(0, Math.min(PLAYER_RANK_HEADER.length(), 16)));
+    // Called by PlayerQuitEvent listener
+    public static void teardownPlayer(Player p) {
+        ScoreboardWrapper wrapper = PLAYER_SCOREBOARDS.remove(p.getName());
+        if (wrapper.revertTask != null) {
+            wrapper.revertTask.cancel();
+        }
+    }
 
-        if (objective == null) {
-            objective = newScoreboard.registerNewObjective(PLAYER_RANK_HEADER.substring(0, Math.min(PLAYER_RANK_HEADER.length(), 16)), "dummy");
+    // Called in onDisable()
+    public static void teardownAll() {
+        for (Player p : Bukkit.getOnlinePlayers()) {
+            teardownPlayer(p);
         }
+    }
 
-        updatePlayerRankScores(player, objective);
-        changeScoreboard(player, oldScoreboard, newScoreboard, Config.getInstance().getMcrankScoreboardTime());
+    // Called by ScoreboardWrapper when its Player logs off and an action tries to be performed
+    public static void cleanup(ScoreboardWrapper wrapper) {
+        PLAYER_SCOREBOARDS.remove(wrapper.playerName);
+        if (wrapper.revertTask != null) {
+            wrapper.revertTask.cancel();
+        }
     }
 
-    public static void enablePlayerRankScoreboardOthers(Player player, String targetName) {
-        Scoreboard oldScoreboard = player.getScoreboard();
-        Scoreboard newScoreboard = PLAYER_SCOREBOARDS.get(player.getName());
-        Objective objective = newScoreboard.getObjective(PLAYER_RANK_HEADER.substring(0, Math.min(PLAYER_RANK_HEADER.length(), 16)));
+    // Called by internal level-up event listener
+    public static void handleLevelUp(Player player, SkillType skill) {
+        // Selfboards
+        ScoreboardWrapper wrapper = PLAYER_SCOREBOARDS.get(player.getName());
+        if ((wrapper.isSkillScoreboard() && wrapper.targetSkill == skill) || (wrapper.isStatsScoreboard()) && wrapper.isBoardShown()) {
+            wrapper.doSidebarUpdateSoon();
+        }
 
-        if (objective == null) {
-            objective = newScoreboard.registerNewObjective(PLAYER_RANK_HEADER.substring(0, Math.min(PLAYER_RANK_HEADER.length(), 16)), "dummy");
+        // Otherboards
+        String playerName = player.getName();
+        for (ScoreboardWrapper w : PLAYER_SCOREBOARDS.values()) {
+            if (w.isStatsScoreboard() && playerName.equals(w.targetPlayer) && wrapper.isBoardShown()) {
+                wrapper.doSidebarUpdateSoon();
+            }
         }
 
-        updatePlayerRankOthersScores(targetName, objective);
-        changeScoreboard(player, oldScoreboard, newScoreboard, Config.getInstance().getMcrankScoreboardTime());
-    }
+        if (Config.getInstance().getPowerLevelTagsEnabled()) {
+            dirtyPowerLevels.add(player.getName());
+        }
 
-    public static void enablePlayerInspectScoreboardOnline(Player player, McMMOPlayer mcMMOTarget) {
-        Scoreboard oldScoreboard = player.getScoreboard();
-        Scoreboard newScoreboard = PLAYER_SCOREBOARDS.get(player.getName());
-        Objective objective = newScoreboard.getObjective(PLAYER_INSPECT_HEADER.substring(0, Math.min(PLAYER_INSPECT_HEADER.length(), 16)));
+        if (Config.getInstance().getSkillLevelUpBoard()) {
+            enablePlayerSkillLevelUpScoreboard(player, skill);
+        }
+    }
 
-        if (objective == null) {
-            objective = newScoreboard.registerNewObjective(PLAYER_INSPECT_HEADER.substring(0, Math.min(PLAYER_INSPECT_HEADER.length(), 16)), "dummy");
+    // Called by internal xp event listener
+    public static void handleXp(Player player, SkillType skill) {
+        // Selfboards
+        ScoreboardWrapper wrapper = PLAYER_SCOREBOARDS.get(player.getName());
+        if (wrapper.isSkillScoreboard() && wrapper.targetSkill == skill && wrapper.isBoardShown()) {
+            wrapper.doSidebarUpdateSoon();
         }
+    }
 
-        updatePlayerInspectOnlineScores(mcMMOTarget, objective);
-        changeScoreboard(player, oldScoreboard, newScoreboard, Config.getInstance().getInspectScoreboardTime());
+    // Called by internal ability event listeners
+    public static void cooldownUpdate(Player player, SkillType skill, int cooldownSeconds) {
+        // Selfboards
+        ScoreboardWrapper wrapper = PLAYER_SCOREBOARDS.get(player.getName());
+        if ((wrapper.isCooldownScoreboard() || wrapper.isSkillScoreboard() && wrapper.targetSkill == skill) && wrapper.isBoardShown()) {
+            wrapper.doSidebarUpdateSoon();
+        }
     }
 
-    public static void enablePlayerInspectScoreboardOffline(Player player, PlayerProfile targetProfile) {
-        Scoreboard oldScoreboard = player.getScoreboard();
-        Scoreboard newScoreboard = PLAYER_SCOREBOARDS.get(player.getName());
-        Objective objective = newScoreboard.getObjective(PLAYER_INSPECT_HEADER.substring(0, Math.min(PLAYER_INSPECT_HEADER.length(), 16)));
+    // **** Setup methods **** //
 
-        if (objective == null) {
-            objective = newScoreboard.registerNewObjective(PLAYER_INSPECT_HEADER.substring(0, Math.min(PLAYER_INSPECT_HEADER.length(), 16)), "dummy");
-        }
+    public static void enablePlayerSkillScoreboard(Player player, SkillType skill) {
+        ScoreboardWrapper wrapper = PLAYER_SCOREBOARDS.get(player.getName());
+
+        wrapper.setOldScoreboard();
+        wrapper.setTypeSkill(skill);
 
-        updatePlayerInspectOfflineScores(targetProfile, objective);
-        changeScoreboard(player, oldScoreboard, newScoreboard, Config.getInstance().getInspectScoreboardTime());
+        changeScoreboard(wrapper, Config.getInstance().getSkillScoreboardTime());
     }
 
-    public static void enableGlobalStatsScoreboard(Player player, String skillName, int pageNumber) {
-        Objective oldObjective = GLOBAL_STATS_SCOREBOARD.getObjective(skillName);
-        Scoreboard oldScoreboard = player.getScoreboard();
+    public static void enablePlayerSkillLevelUpScoreboard(Player player, SkillType skill) {
+        ScoreboardWrapper wrapper = PLAYER_SCOREBOARDS.get(player.getName());
 
-        if (oldObjective != null) {
-            oldObjective.unregister();
+        // Do NOT run if already shown
+        if (wrapper.isBoardShown()) {
+            return;
         }
 
-        Objective newObjective = GLOBAL_STATS_SCOREBOARD.registerNewObjective(skillName, "dummy");
-        newObjective.setDisplayName(ChatColor.GOLD + (skillName.equalsIgnoreCase("all") ? POWER_LEVEL : SkillType.getSkill(skillName).getSkillName()));
+        wrapper.setOldScoreboard();
+        wrapper.setTypeSkill(skill);
 
-        updateGlobalStatsScores(player, newObjective, skillName, pageNumber);
-        changeScoreboard(player, oldScoreboard, GLOBAL_STATS_SCOREBOARD, Config.getInstance().getMctopScoreboardTime());
+        changeScoreboard(wrapper, Config.getInstance().getSkillLevelUpTime());
     }
 
-    private static void updatePlayerSkillScores(PlayerProfile profile, SkillType skill, Objective objective) {
-        Server server = mcMMO.p.getServer();
-        int currentXP = profile.getSkillXpLevel(skill);
+    public static void enablePlayerStatsScoreboard(Player player) {
+        ScoreboardWrapper wrapper = PLAYER_SCOREBOARDS.get(player.getName());
 
-        objective.getScore(server.getOfflinePlayer(LEVEL)).setScore(profile.getSkillLevel(skill));
-        objective.getScore(server.getOfflinePlayer(CURRENT_XP)).setScore(currentXP);
-        objective.getScore(server.getOfflinePlayer(REMAINING_XP)).setScore(profile.getXpToLevel(skill) - currentXP);
+        wrapper.setOldScoreboard();
+        wrapper.setTypeSelfStats();
 
-        objective.setDisplaySlot(DisplaySlot.SIDEBAR);
+        changeScoreboard(wrapper, Config.getInstance().getStatsScoreboardTime());
     }
 
-    private static void updatePlayerStatsScores(McMMOPlayer mcMMOPlayer, Objective objective) {
-        Player player = mcMMOPlayer.getPlayer();
-        PlayerProfile profile = mcMMOPlayer.getProfile();
-        Server server = mcMMO.p.getServer();
+    public static void enablePlayerInspectScoreboard(Player player, PlayerProfile targetProfile) {
+        ScoreboardWrapper wrapper = PLAYER_SCOREBOARDS.get(player.getName());
 
-        for (SkillType skill : SkillType.NON_CHILD_SKILLS) {
-            if (!Permissions.skillEnabled(player, skill)) {
-                continue;
-            }
+        wrapper.setOldScoreboard();
+        wrapper.setTypeInspectStats(targetProfile);
 
-            objective.getScore(server.getOfflinePlayer(skill.getSkillName())).setScore(profile.getSkillLevel(skill));
-        }
-
-        objective.getScore(server.getOfflinePlayer(ChatColor.GOLD + POWER_LEVEL)).setScore(mcMMOPlayer.getPowerLevel());
-        objective.setDisplaySlot(DisplaySlot.SIDEBAR);
+        changeScoreboard(wrapper, Config.getInstance().getInspectScoreboardTime());
     }
 
-    private static void updatePlayerRankScores(Player player, Objective objective) {
-        String playerName = player.getName();
-        Server server = mcMMO.p.getServer();
-        Integer rank;
-
-        Map<String, Integer> skills = mcMMO.getDatabaseManager().readRank(playerName);
+    public static void enablePlayerCooldownScoreboard(Player player) {
+        ScoreboardWrapper wrapper = PLAYER_SCOREBOARDS.get(player.getName());
 
-        for (SkillType skill : SkillType.NON_CHILD_SKILLS) {
-            if (!Permissions.skillEnabled(player, skill)) {
-                continue;
-            }
+        wrapper.setOldScoreboard();
+        wrapper.setTypeCooldowns();
 
-            rank = skills.get(skill.name());
-
-            if (rank != null) {
-                objective.getScore(server.getOfflinePlayer(skill.getSkillName())).setScore(rank);
-            }
-        }
+        changeScoreboard(wrapper, Config.getInstance().getCooldownScoreboardTime());
+    }
 
-        rank = skills.get("ALL");
+    public static void showPlayerRankScoreboard(Player bukkitPlayer, Map<SkillType, Integer> rank) {
+        ScoreboardWrapper wrapper = PLAYER_SCOREBOARDS.get(bukkitPlayer.getName());
 
-        if (rank != null) {
-            objective.getScore(server.getOfflinePlayer(ChatColor.GOLD + OVERALL)).setScore(rank);
-        }
+        wrapper.setOldScoreboard();
+        wrapper.setTypeSelfRank();
+        wrapper.acceptRankData(rank);
 
-        objective.setDisplaySlot(DisplaySlot.SIDEBAR);
+        changeScoreboard(wrapper, Config.getInstance().getRankScoreboardTime());
     }
 
-    private static void updatePlayerRankOthersScores(String targetName, Objective objective) {
-        Server server = mcMMO.p.getServer();
-        Integer rank;
-
-        Map<String, Integer> skills = mcMMO.getDatabaseManager().readRank(targetName);
+    public static void showPlayerRankScoreboardOthers(Player bukkitPlayer, String targetName, Map<SkillType, Integer> rank) {
+        ScoreboardWrapper wrapper = PLAYER_SCOREBOARDS.get(bukkitPlayer.getName());
 
-        for (SkillType skill : SkillType.NON_CHILD_SKILLS) {
-            rank = skills.get(skill.name());
+        wrapper.setOldScoreboard();
+        wrapper.setTypeInspectRank(targetName);
+        wrapper.acceptRankData(rank);
 
-            if (rank != null) {
-                objective.getScore(server.getOfflinePlayer(skill.getSkillName())).setScore(rank);
-            }
-        }
+        changeScoreboard(wrapper, Config.getInstance().getRankScoreboardTime());
+    }
 
-        rank = skills.get("ALL");
+    public static void showTopScoreboard(Player player, SkillType skill, int pageNumber, List<PlayerStat> stats) {
+        ScoreboardWrapper wrapper = PLAYER_SCOREBOARDS.get(player.getName());
 
-        if (rank != null) {
-            objective.getScore(server.getOfflinePlayer(ChatColor.GOLD + OVERALL)).setScore(rank);
-        }
+        wrapper.setOldScoreboard();
+        wrapper.setTypeTop(skill, pageNumber);
+        wrapper.acceptLeaderboardData(stats);
 
-        objective.setDisplayName(PLAYER_RANK_HEADER + ": " + targetName);
-        objective.setDisplaySlot(DisplaySlot.SIDEBAR);
+        changeScoreboard(wrapper, Config.getInstance().getTopScoreboardTime());
     }
 
-    private static void updatePlayerInspectOnlineScores(McMMOPlayer mcMMOTarget, Objective objective) {
-        Player target = mcMMOTarget.getPlayer();
-        PlayerProfile profile = mcMMOTarget.getProfile();
-        Server server = mcMMO.p.getServer();
-        int powerLevel = 0;
-        int skillLevel;
+    public static void showTopPowerScoreboard(Player player, int pageNumber, List<PlayerStat> stats) {
+        ScoreboardWrapper wrapper = PLAYER_SCOREBOARDS.get(player.getName());
 
-        for (SkillType skill : SkillType.NON_CHILD_SKILLS) {
-            if (!Permissions.skillEnabled(target, skill)) {
-                continue;
-            }
-
-            skillLevel = profile.getSkillLevel(skill);
-            objective.getScore(server.getOfflinePlayer(skill.getSkillName())).setScore(skillLevel);
-            powerLevel += skillLevel;
-        }
+        wrapper.setOldScoreboard();
+        wrapper.setTypeTopPower(pageNumber);
+        wrapper.acceptLeaderboardData(stats);
 
-        objective.getScore(server.getOfflinePlayer(ChatColor.GOLD + POWER_LEVEL)).setScore(powerLevel);
-        objective.setDisplayName(PLAYER_INSPECT_HEADER + target.getName());
-        objective.setDisplaySlot(DisplaySlot.SIDEBAR);
+        changeScoreboard(wrapper, Config.getInstance().getTopScoreboardTime());
     }
 
-    private static void updatePlayerInspectOfflineScores(PlayerProfile targetProfile, Objective objective) {
-        Server server = mcMMO.p.getServer();
-        int powerLevel = 0;
-        int skillLevel;
+    // **** Helper methods **** //
 
-        for (SkillType skill : SkillType.NON_CHILD_SKILLS) {
-            skillLevel = targetProfile.getSkillLevel(skill);
-            objective.getScore(server.getOfflinePlayer(skill.getSkillName())).setScore(skillLevel);
-            powerLevel += skillLevel;
+    /**
+     * @return false if power levels are disabled
+     */
+    public static boolean powerLevelHeartbeat() {
+        Objective mainObjective = getPowerLevelObjective();
+        if (mainObjective == null) {
+            return false; // indicates
         }
 
-        objective.getScore(server.getOfflinePlayer(ChatColor.GOLD + POWER_LEVEL)).setScore(powerLevel);
-        objective.setDisplayName(PLAYER_INSPECT_HEADER + targetProfile.getPlayerName());
-    }
+        if (!dirtyPowerLevels.isEmpty())
+            mcMMO.p.getLogger().info(dirtyPowerLevels.toString());
+        for (String playerName : dirtyPowerLevels) {
+            McMMOPlayer mcpl = UserManager.getPlayer(playerName);
+            Player player = mcpl.getPlayer();
+            int power = mcpl.getPowerLevel();
 
-    private static void updateGlobalStatsScores(Player player, Objective objective, String skillName, int pageNumber) {
-        int position = (pageNumber * 15) - 14;
-        String startPosition = ((position < 10) ? "0" : "") + String.valueOf(position);
-        String endPosition = String.valueOf(position + 14);
-        Server server = mcMMO.p.getServer();
+            mainObjective.getScore(player).setScore(power);
+            for (ScoreboardWrapper wrapper : PLAYER_SCOREBOARDS.values()) {
+                wrapper.updatePowerLevel(player, power);
+            }
+        }
+        dirtyPowerLevels.clear();
 
-        for (PlayerStat stat : mcMMO.getDatabaseManager().readLeaderboard(skillName, pageNumber, 15)) {
-            String playerName = stat.name;
-            playerName = (playerName.equals(player.getName()) ? ChatColor.GOLD : "") + playerName;
+        return true;
+    }
 
-            if (playerName.length() > 16) {
-                playerName = playerName.substring(0, 16);
+    /**
+     * Gets or creates the power level objective on the main scoreboard.
+     * <p>
+     * If power levels are disabled, the objective is deleted and null is
+     * returned.
+     *
+     * @return the main scoreboard objective, or null if disabled
+     */
+    public static Objective getPowerLevelObjective() {
+        if (!Config.getInstance().getPowerLevelTagsEnabled()) {
+            Objective obj = Bukkit.getScoreboardManager().getMainScoreboard().getObjective(POWER_OBJECTIVE);
+            if (obj != null) {
+                obj.unregister();
+                mcMMO.p.debug("Removed leftover scoreboard objects from Power Level Tags.");
             }
-
-            objective.getScore(server.getOfflinePlayer(playerName)).setScore(stat.statVal);
+            return null;
         }
 
-        objective.setDisplayName(objective.getDisplayName() + " (" + startPosition + " - " + endPosition + ")");
-        objective.setDisplaySlot(DisplaySlot.SIDEBAR);
+        Objective powerObj = Bukkit.getScoreboardManager().getMainScoreboard().getObjective(POWER_OBJECTIVE);
+        if (powerObj == null) {
+            powerObj = Bukkit.getScoreboardManager().getMainScoreboard().registerNewObjective(POWER_OBJECTIVE, "dummy");
+            powerObj.setDisplayName(TAG_POWER_LEVEL);
+            powerObj.setDisplaySlot(DisplaySlot.BELOW_NAME);
+        }
+        return powerObj;
     }
 
-    private static void changeScoreboard(Player player, Scoreboard oldScoreboard, Scoreboard newScoreboard, int displayTime) {
-        if (oldScoreboard != newScoreboard) {
-            String playerName = player.getName();
+    private static void changeScoreboard(ScoreboardWrapper wrapper, int displayTime) {
+        if (displayTime == -1) {
+            wrapper.showBoardWithNoRevert();
+        }
+        else {
+            wrapper.showBoardAndScheduleRevert(displayTime * Misc.TICK_CONVERSION_FACTOR);
+        }
+    }
 
-            player.setScoreboard(newScoreboard);
-            enablePowerLevelDisplay(player);
+    public static void clearBoard(String playerName) {
+        PLAYER_SCOREBOARDS.get(playerName).tryRevertBoard();
+    }
 
-            if (displayTime != -1 && !SCOREBOARD_TASKS.contains(playerName)) {
-                new ScoreboardChangeTask(player, oldScoreboard).runTaskLater(mcMMO.p, displayTime * Misc.TICK_CONVERSION_FACTOR);
-                SCOREBOARD_TASKS.add(playerName);
-            }
+    public static void keepBoard(String playerName) {
+        if (Config.getInstance().getAllowKeepBoard()) {
+            PLAYER_SCOREBOARDS.get(playerName).cancelRevert();
         }
     }
 
-    public static void clearPendingTask(String playerName) {
-        SCOREBOARD_TASKS.remove(playerName);
+    public static void setRevertTimer(String playerName, int seconds) {
+        PLAYER_SCOREBOARDS.get(playerName).showBoardAndScheduleRevert(seconds * Misc.TICK_CONVERSION_FACTOR);;
     }
 }

+ 543 - 0
src/main/java/com/gmail/nossr50/util/scoreboards/ScoreboardWrapper.java

@@ -0,0 +1,543 @@
+package com.gmail.nossr50.util.scoreboards;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.apache.commons.lang.Validate;
+import org.bukkit.Bukkit;
+import org.bukkit.ChatColor;
+import org.bukkit.entity.Player;
+import org.bukkit.scheduler.BukkitRunnable;
+import org.bukkit.scheduler.BukkitTask;
+import org.bukkit.scoreboard.DisplaySlot;
+import org.bukkit.scoreboard.Objective;
+import org.bukkit.scoreboard.Score;
+import org.bukkit.scoreboard.Scoreboard;
+
+import com.gmail.nossr50.mcMMO;
+import com.gmail.nossr50.config.Config;
+import com.gmail.nossr50.datatypes.database.PlayerStat;
+import com.gmail.nossr50.datatypes.player.McMMOPlayer;
+import com.gmail.nossr50.datatypes.player.PlayerProfile;
+import com.gmail.nossr50.datatypes.skills.AbilityType;
+import com.gmail.nossr50.datatypes.skills.SkillType;
+import com.gmail.nossr50.locale.LocaleLoader;
+import com.gmail.nossr50.skills.child.FamilyTree;
+import com.gmail.nossr50.util.Misc;
+import com.gmail.nossr50.util.Permissions;
+import com.gmail.nossr50.util.player.UserManager;
+import com.gmail.nossr50.util.scoreboards.ScoreboardManager.SidebarType;
+import com.gmail.nossr50.util.skills.SkillUtils;
+
+public class ScoreboardWrapper {
+
+    // Initialization variables
+    public final String playerName;
+    private final Scoreboard board;
+    private boolean tippedKeep = false;
+    private boolean tippedClear = false;
+
+    // Internal usage variables (should exist)
+    private SidebarType sidebarType;
+    private Objective sidebarObj;
+    private Objective powerObj;
+
+    // Parameter variables (May be null / invalid)
+    private Scoreboard oldBoard = null;
+    public String targetPlayer = null;
+    public SkillType targetSkill = null;
+    private PlayerProfile targetProfile = null;
+    public int leaderboardPage = -1;
+
+    private ScoreboardWrapper(String playerName, Scoreboard s) {
+        this.playerName = playerName;
+        board = s;
+        sidebarType = SidebarType.NONE;
+        sidebarObj = board.registerNewObjective(ScoreboardManager.SIDEBAR_OBJECTIVE, "dummy");
+        powerObj = board.registerNewObjective(ScoreboardManager.POWER_OBJECTIVE, "dummy");
+        if (Config.getInstance().getPowerLevelTagsEnabled()) {
+            powerObj.setDisplayName(ScoreboardManager.TAG_POWER_LEVEL);
+            powerObj.setDisplaySlot(DisplaySlot.BELOW_NAME);
+            for (McMMOPlayer mcpl : UserManager.getPlayers()) {
+                powerObj.getScore(mcpl.getPlayer()).setScore(mcpl.getPowerLevel());
+            }
+        }
+    }
+
+    public static ScoreboardWrapper create(Player p) {
+        return new ScoreboardWrapper(p.getName(), mcMMO.p.getServer().getScoreboardManager().getNewScoreboard());
+    }
+
+    public BukkitTask updateTask = null;
+    private class ScoreboardQuickUpdate extends BukkitRunnable {
+        @Override
+        public void run() {
+            ScoreboardWrapper.this.updateSidebar();
+            updateTask = null;
+        }
+    }
+
+    public BukkitTask revertTask = null;
+    private class ScoreboardChangeTask extends BukkitRunnable {
+        @Override
+        public void run() {
+            ScoreboardWrapper.this.tryRevertBoard();
+            revertTask = null;
+        }
+    }
+
+    public BukkitTask cooldownTask = null;
+    private class ScoreboardCooldownTask extends BukkitRunnable {
+        @Override
+        public void run() {
+            ScoreboardWrapper wrapper = ScoreboardWrapper.this;
+            // Stop updating if it's no longer something displaying cooldowns
+            if (wrapper.isBoardShown() && (wrapper.isSkillScoreboard() || wrapper.isCooldownScoreboard())) {
+                wrapper.doSidebarUpdateSoon();
+            }
+            else {
+                wrapper.stopCooldownUpdating();
+            }
+        }
+    }
+
+
+    public void doSidebarUpdateSoon() {
+        if (updateTask == null) {
+            // To avoid spamming the scheduler, store the instance and run 2 ticks later
+            updateTask = new ScoreboardQuickUpdate().runTaskLater(mcMMO.p, 2L);
+        }
+    }
+
+    private void startCooldownUpdating() {
+        if (cooldownTask == null) {
+            // Repeat every 5 seconds.
+            // Cancels once all cooldowns are done, using stopCooldownUpdating().
+            cooldownTask = new ScoreboardCooldownTask().runTaskTimer(mcMMO.p, 5 * Misc.TICK_CONVERSION_FACTOR, 5 * Misc.TICK_CONVERSION_FACTOR);
+        }
+    }
+
+    private void stopCooldownUpdating() {
+        if (cooldownTask != null) {
+            try {
+                cooldownTask.cancel();
+            } catch (Throwable ignored) {}
+            cooldownTask = null;
+        }
+    }
+
+    public boolean isSkillScoreboard() {
+        return sidebarType == SidebarType.SKILL_BOARD;
+    }
+
+    public boolean isCooldownScoreboard() {
+        return sidebarType == SidebarType.COOLDOWNS_BOARD;
+    }
+
+    public boolean isStatsScoreboard() {
+        return sidebarType == SidebarType.STATS_BOARD;
+    }
+
+    /**
+     * Set the old scoreboard, for use in reverting.
+     */
+    public void setOldScoreboard() {
+        Player player = Bukkit.getPlayerExact(playerName);
+        if (player == null) {
+            ScoreboardManager.cleanup(this);
+            return;
+        }
+
+        Scoreboard old = player.getScoreboard();
+        if (old == board) { // Already displaying it
+            if (oldBoard == null) {
+                // (Shouldn't happen) Use failsafe value - we're already displaying our board, but we don't have the one we should revert to
+                oldBoard = Bukkit.getScoreboardManager().getMainScoreboard();
+            }
+            else {
+                // Do nothing, we already have a prev board
+            }
+        }
+        else {
+            oldBoard = old;
+        }
+    }
+
+    public void showBoardWithNoRevert() {
+        Player player = Bukkit.getPlayerExact(playerName);
+        if (player == null) {
+            ScoreboardManager.cleanup(this);
+            return;
+        }
+
+        if (revertTask != null) {
+            revertTask.cancel();
+        }
+        player.setScoreboard(board);
+        revertTask = null;
+    }
+
+    public void showBoardAndScheduleRevert(int ticks) {
+        Player player = Bukkit.getPlayerExact(playerName);
+        if (player == null) {
+            ScoreboardManager.cleanup(this);
+            return;
+        }
+
+        if (revertTask != null) {
+            revertTask.cancel();
+        }
+        player.setScoreboard(board);
+        revertTask = new ScoreboardChangeTask().runTaskLater(mcMMO.p, ticks);
+
+        // TODO is there any way to do the time that looks acceptable?
+        // player.sendMessage(LocaleLoader.getString("Commands.Scoreboard.Timer", StringUtils.capitalize(sidebarType.toString().toLowerCase()), ticks / 20F));
+        if (!tippedKeep) {
+            tippedKeep = true;
+            player.sendMessage(LocaleLoader.getString("Commands.Scoreboard.Tip.Keep"));
+        }
+        else if (!tippedClear) {
+            tippedClear = true;
+            player.sendMessage(LocaleLoader.getString("Commands.Scoreboard.Tip.Clear"));
+        }
+    }
+
+    public void tryRevertBoard() {
+        Player player = Bukkit.getPlayerExact(playerName);
+        if (player == null) {
+            ScoreboardManager.cleanup(this);
+            return;
+        }
+
+        if (oldBoard != null) {
+            if (player.getScoreboard() == board) {
+                player.setScoreboard(oldBoard);
+                oldBoard = null;
+            }
+            else {
+                mcMMO.p.debug("Not reverting scoreboard for " + playerName + " - scoreboard was changed by another plugin (Consider disabling the mcMMO scoreboards if you don't want them!)");
+            }
+        }
+        else {
+            // Was already reverted
+        }
+
+        if (revertTask != null) {
+            revertTask.cancel();
+            revertTask = null;
+        }
+        sidebarType = SidebarType.NONE;
+        targetPlayer = null;
+        targetSkill = null;
+        targetProfile = null;
+        leaderboardPage = -1;
+    }
+
+    public boolean isBoardShown() {
+        Player player = Bukkit.getPlayerExact(playerName);
+        if (player == null) {
+            ScoreboardManager.cleanup(this);
+            return false;
+        }
+
+        return player.getScoreboard() == board;
+    }
+
+    public void cancelRevert() {
+        if (revertTask != null) {
+            revertTask.cancel();
+        }
+        revertTask = null;
+    }
+
+    // Board Type Changing 'API' methods
+
+    public void setTypeNone() {
+        this.sidebarType = SidebarType.NONE;
+
+        targetPlayer = null;
+        targetSkill = null;
+        targetProfile = null;
+        leaderboardPage = -1;
+
+        loadObjective("");
+    }
+
+    public void setTypeSkill(SkillType skill) {
+        this.sidebarType = SidebarType.SKILL_BOARD;
+        targetSkill = skill;
+
+        targetPlayer = null;
+        targetProfile = null;
+        leaderboardPage = -1;
+
+        loadObjective(ScoreboardManager.skillLabels.get(skill).getName());
+    }
+
+    public void setTypeSelfStats() {
+        this.sidebarType = SidebarType.STATS_BOARD;
+
+        targetPlayer = null;
+        targetSkill = null;
+        targetProfile = null;
+        leaderboardPage = -1;
+
+        loadObjective(ScoreboardManager.HEADER_STATS);
+    }
+
+    public void setTypeInspectStats(PlayerProfile profile) {
+        this.sidebarType = SidebarType.STATS_BOARD;
+        targetPlayer = profile.getPlayerName();
+        targetProfile = profile;
+
+        targetSkill = null;
+        leaderboardPage = -1;
+
+        loadObjective(LocaleLoader.getString("Scoreboard.Header.PlayerInspect", targetPlayer));
+    }
+
+    public void setTypeCooldowns() {
+        this.sidebarType = SidebarType.COOLDOWNS_BOARD;
+
+        targetPlayer = null;
+        targetSkill = null;
+        targetProfile = null;
+        leaderboardPage = -1;
+
+        loadObjective(ScoreboardManager.HEADER_COOLDOWNS);
+    }
+
+    public void setTypeSelfRank() {
+        this.sidebarType = SidebarType.RANK_BOARD;
+        targetPlayer = null;
+
+        targetSkill = null;
+        targetProfile = null;
+        leaderboardPage = -1;
+
+        loadObjective(ScoreboardManager.HEADER_RANK);
+    }
+
+    public void setTypeInspectRank(String otherPlayer) {
+        this.sidebarType = SidebarType.RANK_BOARD;
+        targetPlayer = otherPlayer;
+
+        targetSkill = null;
+        targetProfile = null;
+        leaderboardPage = -1;
+
+        loadObjective(ScoreboardManager.HEADER_RANK);
+    }
+
+    public void setTypeTopPower(int page) {
+        this.sidebarType = SidebarType.TOP_BOARD;
+        leaderboardPage = page;
+        targetSkill = null;
+
+        targetPlayer = null;
+        targetProfile = null;
+
+        int endPosition = page * 15;
+        int startPosition = endPosition - 14;
+        loadObjective(String.format("%s (%2d - %2d)", ScoreboardManager.POWER_LEVEL, startPosition, endPosition));
+    }
+
+    public void setTypeTop(SkillType skill, int page) {
+        this.sidebarType = SidebarType.TOP_BOARD;
+        leaderboardPage = page;
+        targetSkill = skill;
+
+        targetPlayer = null;
+        targetProfile = null;
+
+        int endPosition = page * 15;
+        int startPosition = endPosition - 14;
+        loadObjective(String.format("%s (%2d - %2d)", ScoreboardManager.skillLabels.get(skill).getName(), startPosition, endPosition));
+    }
+
+    // Setup for after a board type change
+    protected void loadObjective(String displayName) {
+        sidebarObj.unregister();
+        sidebarObj = board.registerNewObjective(ScoreboardManager.SIDEBAR_OBJECTIVE, "dummy");
+
+        if (displayName.length() > 32) {
+            displayName = displayName.substring(0, 32);
+        }
+        sidebarObj.setDisplayName(displayName);
+
+        updateSidebar();
+        // Do last! Minimize packets!
+        sidebarObj.setDisplaySlot(DisplaySlot.SIDEBAR);
+    }
+
+    /**
+     * Load new values into the sidebar.
+     */
+    private void updateSidebar() {
+        try {
+            updateTask.cancel();
+        } catch (Throwable ignored) {} // catch NullPointerException and IllegalStateException and any Error; don't care
+        updateTask = null;
+
+        if (sidebarType == SidebarType.NONE) {
+            return;
+        }
+
+        Player bukkitPlayer = Bukkit.getPlayerExact(playerName);
+        if (bukkitPlayer == null) {
+            ScoreboardManager.cleanup(this);
+            return;
+        }
+
+        McMMOPlayer mcPlayer = UserManager.getPlayer(bukkitPlayer);
+        PlayerProfile profile = mcPlayer.getProfile();
+
+        switch (sidebarType) {
+        case NONE:
+            break;
+
+        case SKILL_BOARD:
+            Validate.notNull(targetSkill);
+            if (!targetSkill.isChildSkill()) {
+                int currentXP = profile.getSkillXpLevel(targetSkill);
+                sidebarObj.getScore(ScoreboardManager.LABEL_CURRENT_XP).setScore(currentXP);
+                sidebarObj.getScore(ScoreboardManager.LABEL_REMAINING_XP).setScore(profile.getXpToLevel(targetSkill) - currentXP);
+            }
+            else {
+                Set<SkillType> parents = FamilyTree.getParents(targetSkill);
+                for (SkillType parentSkill : parents) {
+                    sidebarObj.getScore(ScoreboardManager.skillLabels.get(parentSkill)).setScore(profile.getSkillLevel(parentSkill));
+                }
+            }
+            sidebarObj.getScore(ScoreboardManager.LABEL_LEVEL).setScore(profile.getSkillLevel(targetSkill));
+            if (targetSkill.getAbility() != null) {
+                if (targetSkill != SkillType.MINING) {
+                    AbilityType ab = targetSkill.getAbility();
+                    Score cooldown = sidebarObj.getScore(ScoreboardManager.abilityLabelsSkill.get(ab));
+                    int seconds = SkillUtils.calculateTimeLeft(profile.getSkillDATS(ab) * Misc.TIME_CONVERSION_FACTOR, ab.getCooldown(), bukkitPlayer);
+                    seconds = (seconds <= 0) ? 0 : seconds;
+                    if (seconds == 0) {
+                        cooldown.setScore(0);
+                        stopCooldownUpdating();
+                    }
+                    else {
+                        cooldown.setScore(seconds);
+                        startCooldownUpdating();
+                    }
+                } else {
+                    // Special-Case: Mining has two abilities, both with cooldowns
+                    AbilityType sb = AbilityType.SUPER_BREAKER;
+                    AbilityType bm = AbilityType.BLAST_MINING;
+                    Score cooldownSB = sidebarObj.getScore(ScoreboardManager.abilityLabelsSkill.get(sb));
+                    Score cooldownBM = sidebarObj.getScore(ScoreboardManager.abilityLabelsSkill.get(bm));
+                    int secondsSB = SkillUtils.calculateTimeLeft(profile.getSkillDATS(sb) * Misc.TIME_CONVERSION_FACTOR, sb.getCooldown(), bukkitPlayer);
+                    int secondsBM = SkillUtils.calculateTimeLeft(profile.getSkillDATS(bm) * Misc.TIME_CONVERSION_FACTOR, bm.getCooldown(), bukkitPlayer);
+                    secondsSB = (secondsSB <= 0) ? 0 : secondsSB;
+                    secondsBM = (secondsBM <= 0) ? 0 : secondsBM;
+                    if (secondsSB == 0 && secondsBM == 0) {
+                        cooldownSB.setScore(0);
+                        cooldownBM.setScore(0);
+                        stopCooldownUpdating();
+                    }
+                    else {
+                        cooldownSB.setScore(secondsSB);
+                        cooldownBM.setScore(secondsBM);
+                        startCooldownUpdating();
+                    }
+                }
+            }
+            break;
+
+        case COOLDOWNS_BOARD:
+            boolean anyCooldownsActive = false;
+            for (AbilityType ability : AbilityType.NORMAL_ABILITIES) {
+                int seconds = SkillUtils.calculateTimeLeft(profile.getSkillDATS(ability) * Misc.TIME_CONVERSION_FACTOR, ability.getCooldown(), bukkitPlayer);
+                seconds = (seconds <= 0) ? 0 : seconds;
+                if (seconds != 0) {
+                    anyCooldownsActive = true;
+                }
+                sidebarObj.getScore(ScoreboardManager.abilityLabelsColored.get(ability)).setScore(seconds);
+            }
+
+            if (anyCooldownsActive) {
+                startCooldownUpdating();
+            }
+            else {
+                stopCooldownUpdating();
+            }
+            break;
+
+        case STATS_BOARD:
+            // Select the profile to read from
+            PlayerProfile prof;
+            if (targetProfile != null) {
+                prof = targetProfile; // offline
+            }
+            else if (targetPlayer == null) {
+                prof = profile; // self
+            }
+            else {
+                prof = UserManager.getPlayer(targetPlayer).getProfile(); // online
+            }
+            // Calculate power level here
+            int powerLevel = 0;
+            for (SkillType skill : SkillType.values()) { // Include child skills, but not in power level
+                int level = prof.getSkillLevel(skill);
+                if (!skill.isChildSkill())
+                    powerLevel += level;
+
+                // TODO: Verify that this is what we want - calculated in power level but not displayed
+                if (!Permissions.skillEnabled(bukkitPlayer, skill)) {
+                    continue;
+                }
+                sidebarObj.getScore(ScoreboardManager.skillLabels.get(skill)).setScore(level);
+            }
+            sidebarObj.getScore(ScoreboardManager.LABEL_POWER_LEVEL).setScore(powerLevel);
+            break;
+
+        case RANK_BOARD:
+        case TOP_BOARD:
+            /*
+             * @see #acceptRankData(Map<SkillType, Integer> rank)
+             * @see #acceptLeaderboardData(List<PlayerStat> stats)
+             */
+            break;
+
+        }
+    }
+
+    public void acceptRankData(Map<SkillType, Integer> rankData) {
+        Integer rank;
+        Player bukkitPlayer = Bukkit.getPlayerExact(playerName);
+
+        for (SkillType skill : SkillType.NON_CHILD_SKILLS) {
+            if (!Permissions.skillEnabled(bukkitPlayer, skill)) {
+                continue;
+            }
+
+            rank = rankData.get(skill);
+            if (rank != null) {
+                sidebarObj.getScore(ScoreboardManager.skillLabels.get(skill)).setScore(rank);
+            }
+        }
+        rank = rankData.get(null);
+        if (rank != null) {
+            sidebarObj.getScore(ScoreboardManager.LABEL_POWER_LEVEL).setScore(rank);
+        }
+    }
+
+    public void acceptLeaderboardData(List<PlayerStat> leaderboardData) {
+        for (PlayerStat stat : leaderboardData) {
+            String statname = stat.name;
+            if (statname.equals(playerName)) {
+                statname = ChatColor.GOLD + "--You--";
+            }
+            sidebarObj.getScore(Bukkit.getOfflinePlayer(statname)).setScore(stat.statVal);
+        }
+    }
+
+    public void updatePowerLevel(Player leveledPlayer, int newPowerLevel) {
+        powerObj.getScore(leveledPlayer).setScore(newPowerLevel);
+    }
+}

+ 56 - 29
src/main/resources/config.yml

@@ -27,35 +27,62 @@ General:
     # Should mcMMO over-write configs to update, or make new ones ending in .new?
     Config_Update_Overwrite: true
 
-Scoreboards:
-    # Should mcMMO use scoreboards for /inspect?
-    # Amount of time (in seconds) to display. To display permanently, set to -1
-    Inspect:
-        Use: true
-        Display_Time: 10
-    # Should mcMMO use scoreboards for /mcrank?
-    # Amount of time (in seconds) to display. To display permanently, set to -1
-    Mcrank:
-        Use: true
-        Display_Time: 10
-    # Should mcMMO use scoreboards for /mcstats?
-    # Amount of time (in seconds) to display. To display permanently, set to -1
-    Mcstats:
-        Use: true
-        Display_Time: 10
-    # Should mcMMO use scoreboards for /mctop?
-    # Amount of time (in seconds) to display. To display permanently, set to -1
-    Mctop:
-        Use: true
-        Display_Time: 10
-    # Should mcMMO use scoreboards for /skillname (/mining, /fishing, etc.)?
-    # Amount of time (in seconds) to display. To display permanently, set to -1
-    Skillname:
-        Use: true
-        Display_Time: 10
-    # Should mcMMO display power levels on scoreboards? (below player name-tags)
-    Power_Level:
-        Use: false
+#
+#  Settings for the mcMMO scoreboards
+###
+Scoreboard:
+    # Display player's power levels below their names?
+    Power_Level_Tags: false
+
+    # Allow players to use "/mcscoreboard keep" to keep the scoreboard up
+    Allow_Keep: true
+
+    # Add some more color on the board :-)
+    Rainbows: false
+
+    # Settings for each type of scoreboard
+    Types:
+        # Settings for /mcrank
+        # The sub-options (Print, Board, Display_Time) are the same for each type.
+        Rank:
+            # Should the command output be printed in chat?
+            Print: false
+            # Should the command output be displayed in the scoreboard sidebar?
+            Board: true
+            # Amount of time (seconds) to display in the sidebar before clearing.
+            # To display permanently, use "/mcscoreboard keep" or set to -1
+            Display_Time: 15
+        # Settings for /mctop
+        Top:
+            Print: true
+            Board: true
+            Display_Time: 15
+        # Settings for /mcstats
+        Stats:
+            Print: false
+            Board: true
+            Display_Time: 15
+        # Settings for /inspect
+        Inspect:
+            Print: false
+            Board: true
+            Display_Time: 20
+        # Settings for /mccooldown
+        Cooldown:
+            Print: true
+            Board: true
+            Display_Time: 41
+        # Settings for /<skillname> (e.g. /mining, /unarmed)
+        # No "print" option is given here; the information will always be displayed in chat.
+        # It should also be noted that this display is pretty dang cool.
+        Skill:
+            Board: true
+            Display_Time: 30
+
+            # Should the board be shown when a player levels up, and for how long?
+            # It is recommended to NOT have LevelUp_Time be -1, as this may confuse players.
+            LevelUp_Board: true
+            LevelUp_Time: 5
 
 Mob_Healthbar:
     # Default display for mob health bars - HEARTS, BAR, or DISABLED

+ 34 - 9
src/main/resources/locale/locale_en_US.properties

@@ -74,6 +74,7 @@ Axes.Effect.8=Greater Impact
 Axes.Effect.9=Deal bonus damage to unarmored foes
 Axes.Listener=Axes: 
 Axes.SkillName=AXES
+Axes.Skills.SS.Name=Skull Splitter
 Axes.Skills.SS.Off=[[RED]]**Skull Splitter has worn off**
 Axes.Skills.SS.On=[[GREEN]]**Skull Splitter ACTIVATED**
 Axes.Skills.SS.Refresh=[[GREEN]]Your [[YELLOW]]Skull Splitter [[GREEN]]ability is refreshed!
@@ -91,6 +92,7 @@ Excavation.Effect.3=Ability to dig for treasure
 Excavation.Effect.Length=[[RED]]Giga Drill Breaker Length: [[YELLOW]]{0}s
 Excavation.Listener=Excavation: 
 Excavation.SkillName=EXCAVATION
+Excavation.Skills.GigaDrillBreaker.Name=Giga Drill Breaker
 Excavation.Skills.GigaDrillBreaker.Off=[[RED]]**Giga Drill Breaker has worn off**
 Excavation.Skills.GigaDrillBreaker.On=[[GREEN]]**GIGA DRILL BREAKER ACTIVATED**
 Excavation.Skills.GigaDrillBreaker.Refresh=[[GREEN]]Your [[YELLOW]]Giga Drill Breaker [[GREEN]]ability is refreshed!
@@ -162,6 +164,7 @@ Herbalism.Effect.13=Spread mycelium to dirt & grass
 Herbalism.HylianLuck=[[GREEN]]The luck of Hyrule is with you today!
 Herbalism.Listener=Herbalism: 
 Herbalism.SkillName=HERBALISM
+Herbalism.Skills.GTe.Name=Green Terra
 Herbalism.Skills.GTe.Off=[[RED]]**Green Terra has worn off**
 Herbalism.Skills.GTe.On=[[GREEN]]**GREEN TERRA ACTIVATED**
 Herbalism.Skills.GTe.Refresh=[[GREEN]]Your [[YELLOW]]Green Terra [[GREEN]]ability is refreshed!
@@ -190,6 +193,7 @@ Mining.Effect.Decrease=[[RED]]Demolitions Expert Damage Decrease: [[YELLOW]]{0}
 Mining.Effect.DropChance=[[RED]]Double Drop Chance: [[YELLOW]]{0}
 Mining.Listener=Mining: 
 Mining.SkillName=MINING
+Mining.Skills.SuperBreaker.Name=Super Breaker
 Mining.Skills.SuperBreaker.Off=[[RED]]**Super Breaker has worn off**
 Mining.Skills.SuperBreaker.On=[[GREEN]]**SUPER BREAKER ACTIVATED**
 Mining.Skills.SuperBreaker.Other.Off=[[RED]]Super Breaker[[GREEN]] has worn off for [[YELLOW]]{0}
@@ -198,6 +202,7 @@ Mining.Skills.SuperBreaker.Refresh=[[GREEN]]Your [[YELLOW]]Super Breaker [[GREEN
 Mining.Skillup=[[YELLOW]]Mining skill increased by {0}. Total ({1})
 
 #Blast Mining
+Mining.Blast.Name=Blast Mining
 Mining.Blast.Boom=[[GRAY]]**BOOM**
 Mining.Blast.Effect=+{0} ore yield, -{1} debris yield, {2}x drops
 Mining.Blast.Radius.Increase=[[RED]]Blast Radius Increase: [[YELLOW]]+{0}
@@ -278,6 +283,7 @@ Swords.Effect.6=Bleed
 Swords.Effect.7=Apply a bleed DoT
 Swords.Listener=Swords: 
 Swords.SkillName=SWORDS
+Swords.Skills.SS.Name=Serrated Strikes
 Swords.Skills.SS.Off=[[RED]]**Serrated Strikes has worn off**
 Swords.Skills.SS.On=[[GREEN]]**SERRATED STRIKES ACTIVATED**
 Swords.Skills.SS.Refresh=[[GREEN]]Your [[YELLOW]]Serrated Strikes [[GREEN]]ability is refreshed!
@@ -360,6 +366,7 @@ Unarmed.Effect.8=Iron Grip
 Unarmed.Effect.9=Prevents you from being disarmed
 Unarmed.Listener=Unarmed: 
 Unarmed.SkillName=UNARMED
+Unarmed.Skills.Berserk.Name=Berserk
 Unarmed.Skills.Berserk.Off=[[RED]]**Berserk has worn off**
 Unarmed.Skills.Berserk.On=[[GREEN]]**BERSERK ACTIVATED**
 Unarmed.Skills.Berserk.Other.Off=[[RED]]Berserk[[GREEN]] has worn off for [[YELLOW]]{0}
@@ -381,6 +388,7 @@ Woodcutting.Effect.4=Double Drops
 Woodcutting.Effect.5=Double the normal loot
 Woodcutting.Listener=Woodcutting: 
 Woodcutting.SkillName=WOODCUTTING
+Woodcutting.Skills.TreeFeller.Name=Tree Feller
 Woodcutting.Skills.TreeFeller.Off=[[RED]]**Tree Feller has worn off**
 Woodcutting.Skills.TreeFeller.On=[[GREEN]]**TREE FELLER ACTIVATED**
 Woodcutting.Skills.TreeFeller.Refresh=[[GREEN]]Your [[YELLOW]]Tree Feller [[GREEN]]ability is refreshed!
@@ -422,6 +430,10 @@ Commands.AdminChat.Off=Admin Chat only [[RED]]Off
 Commands.AdminChat.On=Admin Chat only [[GREEN]]On
 Commands.AdminToggle=[[RED]]- Toggle admin chat
 Commands.Chat.Console=*Console*
+Commands.Cooldowns.Header=[[GOLD]]--= [[GREEN]]mcMMO Ability Cooldowns[[GOLD]] =--
+Commands.Cooldowns.Row.N=\  [[RED]]{0}[[WHITE]] - [[GOLD]]{1} seconds left
+Commands.Cooldowns.Row.Y=\  [[AQUA]]{0}[[WHITE]] - [[DARK_GREEN]]Ready!
+Commands.Database.Cooldown=[[RED]]You must wait 1 second before using this command again.
 Commands.Disabled=[[RED]]This command is disabled.
 Commands.DoesNotExist= [[RED]]Player does not exist in the database!
 Commands.GodMode.Disabled=[[YELLOW]]mcMMO Godmode Disabled
@@ -511,6 +523,15 @@ Commands.PowerLevel=[[DARK_RED]]POWER LEVEL: [[GREEN]]{0}
 Commands.Reset.All=[[GREEN]]All of your skill levels have been reset successfully.
 Commands.Reset.Single=[[GREEN]]Your {0} skill level has been reset successfully.
 Commands.Reset=[[RED]]Reset a skill's level to 0
+Commands.Scoreboard.Clear=[[DARK_AQUA]]mcMMO scoreboard cleared.
+Commands.Scoreboard.Keep=[[DARK_AQUA]]The mcMMO scoreboard will stay up until you use [[GREEN]]/mcscoreboard clear[[DARK_AQUA]].
+#Commands.Scoreboard.Timer=[[BLUE]]This scoreboard will remain visible for [[GOLD]]{1}[[BLUE]] seconds.
+Commands.Scoreboard.Help.0=[[GOLD]] == [[GREEN]]Help for [[RED]]/mcscoreboard[[GOLD]] == 
+Commands.Scoreboard.Help.1=[[DARK_AQUA]]/mcscoreboard[[AQUA]] clear [[WHITE]] - clear the McMMO scoreboard
+Commands.Scoreboard.Help.2=[[DARK_AQUA]]/mcscoreboard[[AQUA]] keep [[WHITE]] - keep the mcMMO scoreboard up
+Commands.Scoreboard.Help.3=[[DARK_AQUA]]/mcscoreboard[[AQUA]] time [n] [[WHITE]] - clear the McMMO scoreboard after [[LIGHT_PURPLE]]n[[WHITE]] seconds
+Commands.Scoreboard.Tip.Keep=[[GOLD]]Tip: Use [[RED]]/mcscoreboard keep[[GOLD]] to keep the scoreboard from going away.
+Commands.Scoreboard.Tip.Clear=[[GOLD]]Tip: Use [[RED]]/mcscoreboard clear[[GOLD]] to get rid of the scoreboard.
 Commands.Skill.Invalid=[[RED]]That is not a valid skillname!
 Commands.Skill.Leaderboard=[[YELLOW]]--mcMMO [[BLUE]]{0}[[YELLOW]] Leaderboard--
 Commands.SkillInfo=[[RED]]- View detailed information about a skill
@@ -829,6 +850,7 @@ Commands.Description.addxp=Add mcMMO XP to a user
 Commands.Description.hardcore=Modify the mcMMO hardcore percentage or toggle hardcore mode on/off
 Commands.Description.inspect=View detailed mcMMO info on another player
 Commands.Description.mcability=Toggle mcMMO abilities being readied on right-click on/off
+Commands.Description.mccooldown=View all of the mcMMO ability cooldowns
 Commands.Description.mcgod=Toggle mcMMO god-mode on/off
 Commands.Description.mchud=Change your mcMMO HUD style
 Commands.Description.mcmmo=Show a brief description of mcMMO
@@ -837,6 +859,7 @@ Commands.Description.mcpurge=Purge users with no mcMMO levels and users who have
 Commands.Description.mcrank=Show mcMMO ranking for a player
 Commands.Description.mcrefresh=Refresh all cooldowns for mcMMO
 Commands.Description.mcremove=Remove a user from the mcMMO database
+Commands.Description.mcscoreboard=Manage your mcMMO Scoreboard
 Commands.Description.mcstats=Show your mcMMO levels and XP
 Commands.Description.mctop=Show mcMMO leader boards
 Commands.Description.mmoedit=Edit mcMMO levels for a user
@@ -857,15 +880,17 @@ UpdateChecker.Outdated=You are using an outdated version of mcMMO!
 UpdateChecker.NewAvailable=There is a new version available on BukkitDev.
 
 #SCOREBOARD HEADERS
-Scoreboard.Header.PlayerStats=mcMMO Stats
-Scoreboard.Header.PlayerRank=mcMMO Rankings
-Scoreboard.Header.PlayerInspect=mcMMO Stats: 
-Scoreboard.Header.PowerLevel=Power Level
-Scoreboard.Misc.PowerLevel=Power Level
-Scoreboard.Misc.Level=Level
-Scoreboard.Misc.CurrentXP=Current XP
-Scoreboard.Misc.RemainingXP=Remaining XP
-Scoreboard.Misc.Overall=Overall
+Scoreboard.Header.PlayerStats=[[YELLOW]]mcMMO Stats
+Scoreboard.Header.PlayerCooldowns=[[YELLOW]]mcMMO Cooldowns
+Scoreboard.Header.PlayerRank=[[YELLOW]]mcMMO Rankings
+Scoreboard.Header.PlayerInspect=[[YELLOW]]mcMMO Stats: {0}
+Scoreboard.Header.PowerLevel=[[RED]]Power Level
+Scoreboard.Misc.PowerLevel=[[GOLD]]Power Level
+Scoreboard.Misc.Level=[[DARK_AQUA]]Level
+Scoreboard.Misc.CurrentXP=[[GREEN]]Current XP
+Scoreboard.Misc.RemainingXP=[[YELLOW]]Remaining XP
+Scoreboard.Misc.Cooldown=[[LIGHT_PURPLE]]Cooldown
+Scoreboard.Misc.Overall=[[GOLD]]Overall
 
 #DATABASE RECOVERY
 Recovery.Notice=[[RED]]Notice: mcMMO was [[DARK_RED]]unable to load your data.[[RED]] Retrying 5 times...

+ 7 - 1
src/main/resources/plugin.yml

@@ -36,6 +36,9 @@ commands:
         description: Toggle whether or not abilities get readied on right click
     mcrefresh:
         description: Refresh all cooldowns for mcMMO
+    mccooldown:
+        description: Show the cooldowns on all your mcMMO abilities
+        aliases: [mccooldowns]
     mcgod:
         description: Toggle mcMMO god-mode on/off
     mcstats:
@@ -104,7 +107,7 @@ commands:
         aliases: [mcmobhealth]
         description: Change the style of the mob healthbar
     mcscoreboard:
-        description: Change the current mcMMO scoreboard being displayed
+        description: Manage your mcMMO Scoreboard
     kraken:
         aliases: [mckraken]
         description: Unleash the kraken!
@@ -692,6 +695,7 @@ permissions:
             mcmmo.commands.herbalism: true
             mcmmo.commands.inspect: true
             mcmmo.commands.mcability: true
+            mcmmo.commands.mccooldown: true
             mcmmo.commands.mcmmo.all: true
             mcmmo.commands.mcnotify: true
             mcmmo.commands.mcrank: true
@@ -828,6 +832,8 @@ permissions:
         description: Allows access to the mcconvert command for databases
     mcmmo.commands.mcconvert.experience:
         description: Allows access to the mcconvert command for experience
+    mcmmo.commands.mccooldown:
+        description: Allows access to the mccooldowns command
     mcmmo.commands.mcgod:
         description: Allows access to the mcgod command
     mcmmo.commands.mcgod.others: