Browse Source

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 năm trước cách đây
mục cha
commit
95f15e68fe
33 tập tin đã thay đổi với 1490 bổ sung541 xóa
  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: