diff --git a/pom.xml b/pom.xml index 3007989..d71520b 100644 --- a/pom.xml +++ b/pom.xml @@ -19,7 +19,7 @@ org.spigotmc spigot-api - 1.18-R0.1-SNAPSHOT + 1.18.2-R0.1-SNAPSHOT provided diff --git a/src/main/java/com/gmail/artemis/the/gr8/playerstats/Main.java b/src/main/java/com/gmail/artemis/the/gr8/playerstats/Main.java index d15d36a..e1e95a0 100644 --- a/src/main/java/com/gmail/artemis/the/gr8/playerstats/Main.java +++ b/src/main/java/com/gmail/artemis/the/gr8/playerstats/Main.java @@ -3,6 +3,7 @@ package com.gmail.artemis.the.gr8.playerstats; import com.gmail.artemis.the.gr8.playerstats.commands.ReloadCommand; import com.gmail.artemis.the.gr8.playerstats.commands.StatCommand; import com.gmail.artemis.the.gr8.playerstats.commands.TabCompleter; +import com.gmail.artemis.the.gr8.playerstats.filehandlers.ConfigHandler; import com.gmail.artemis.the.gr8.playerstats.listeners.JoinListener; import com.gmail.artemis.the.gr8.playerstats.utils.EnumHandler; import com.gmail.artemis.the.gr8.playerstats.utils.OfflinePlayerHandler; @@ -14,17 +15,16 @@ public class Main extends JavaPlugin { @Override public void onEnable() { - ConfigHandler config = new ConfigHandler(this); - EnumHandler enumHandler = new EnumHandler(); - + EnumHandler enumHandler = new EnumHandler(this); OutputFormatter outputFormatter = new OutputFormatter(config); - StatManager statManager = new StatManager(enumHandler, this); - this.getCommand("statistic").setExecutor(new StatCommand(outputFormatter, statManager, this)); - this.getCommand("statistic").setTabCompleter(new TabCompleter( - enumHandler, statManager,this)); - this.getCommand("statisticreload").setExecutor(new ReloadCommand(config, outputFormatter)); + //prepare private hashMap of offline players + OfflinePlayerHandler.updateOfflinePlayers(); + + this.getCommand("statistic").setExecutor(new StatCommand(outputFormatter, enumHandler, this)); + this.getCommand("statistic").setTabCompleter(new TabCompleter(enumHandler, this)); + this.getCommand("statisticreload").setExecutor(new ReloadCommand(config, outputFormatter, this)); Bukkit.getPluginManager().registerEvents(new JoinListener(), this); this.getLogger().info("Enabled PlayerStats!"); @@ -35,14 +35,8 @@ public class Main extends JavaPlugin { this.getLogger().info("Disabled PlayerStats!"); } - - public void logStatRelatedExceptions(Exception exception) { - if (exception instanceof IllegalArgumentException) { - getLogger().warning("IllegalArgumentException - this is probably not a valid statistic name!"); - } - else if (exception instanceof NullPointerException) { - getLogger().warning("NullPointerException - no statistic name was provided"); - } + public long logTimeTaken(String className, String methodName, long previousTime, int lineNumber) { + getLogger().info(className + " " + methodName + " " + lineNumber + ": " + (System.currentTimeMillis() - previousTime)); + return System.currentTimeMillis(); } - } diff --git a/src/main/java/com/gmail/artemis/the/gr8/playerstats/StatManager.java b/src/main/java/com/gmail/artemis/the/gr8/playerstats/StatManager.java deleted file mode 100644 index 70f4fd0..0000000 --- a/src/main/java/com/gmail/artemis/the/gr8/playerstats/StatManager.java +++ /dev/null @@ -1,165 +0,0 @@ -package com.gmail.artemis.the.gr8.playerstats; - -import com.gmail.artemis.the.gr8.playerstats.utils.EnumHandler; -import com.gmail.artemis.the.gr8.playerstats.utils.OfflinePlayerHandler; -import org.bukkit.Material; -import org.bukkit.OfflinePlayer; -import org.bukkit.Statistic; -import org.bukkit.entity.EntityType; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.stream.Collectors; - -public class StatManager { - - private final Main plugin; - private final EnumHandler enumHandler; - private final OfflinePlayerHandler offlinePlayerHandler; - private final List statNames; - private final List entityStatNames; - private final List subStatEntryNames; - - public StatManager(EnumHandler e, Main p) { - plugin = p; - enumHandler = e; - offlinePlayerHandler = OfflinePlayerHandler.getInstance(); - - statNames = Arrays.stream(Statistic.values()).map( - Statistic::toString).map(String::toLowerCase).toList(); - entityStatNames = Arrays.stream(Statistic.values()).filter(statistic -> - statistic.getType().equals(Statistic.Type.ENTITY)).map( - Statistic::toString).map(String::toLowerCase).collect(Collectors.toList()); - - subStatEntryNames = new ArrayList<>(); - subStatEntryNames.addAll(enumHandler.getBlockNames()); - subStatEntryNames.addAll(enumHandler.getEntityTypeNames()); - subStatEntryNames.addAll(enumHandler.getItemNames()); - } - - public int getStatistic(String statName, String playerName) throws IllegalArgumentException, NullPointerException { - return getStatistic(statName, null, playerName); - } - - //returns the integer associated with a certain statistic for a player - public int getStatistic(String statName, String subStatEntryName, String playerName) throws IllegalArgumentException, NullPointerException { - long time = System.currentTimeMillis(); - - OfflinePlayer player = offlinePlayerHandler.getOfflinePlayer(playerName); - - plugin.getLogger().info("StatManager 51: " + (System.currentTimeMillis() - time)); - time = System.currentTimeMillis(); - if (player == null) throw new NullPointerException("No player called " + playerName + " was found!"); - - Statistic stat = getStatistic(statName); - plugin.getLogger().info("StatManager 56: " + (System.currentTimeMillis() - time)); - time = System.currentTimeMillis(); - - if (stat != null) { - switch (stat.getType()) { - case UNTYPED -> { - plugin.getLogger().info("StatManager 62: " + (System.currentTimeMillis() - time)); - time = System.currentTimeMillis(); - return player.getStatistic(stat); - } - case BLOCK -> { - plugin.getLogger().info("StatManager 67: " + (System.currentTimeMillis() - time)); - time = System.currentTimeMillis(); - Material block = enumHandler.getBlock(subStatEntryName); - if (block == null) throw new NullPointerException(subStatEntryName + " is not a valid block name!"); - return player.getStatistic(stat, block); - } - case ENTITY -> { - plugin.getLogger().info("StatManager 74: " + (System.currentTimeMillis() - time)); - time = System.currentTimeMillis(); - EntityType entity = enumHandler.getEntityType(subStatEntryName); - if (entity == null) throw new NullPointerException(subStatEntryName + " is not a valid entity name!"); - return player.getStatistic(stat, entity); - } - case ITEM -> { - plugin.getLogger().info("StatManager 81: " + (System.currentTimeMillis() - time)); - time = System.currentTimeMillis(); - Material item = enumHandler.getItem(subStatEntryName); - if (item == null) throw new NullPointerException(subStatEntryName + " is not a valid item name!"); - return player.getStatistic(stat, item); - } - } - } - throw new NullPointerException(statName + " is not a valid statistic name!"); - } - - //returns the statistic enum constant, or null if non-existent (param: statName, not case sensitive) - private Statistic getStatistic(String statName) { - try { - return Statistic.valueOf(statName.toUpperCase()); - } - catch (IllegalArgumentException | NullPointerException exception) { - plugin.logStatRelatedExceptions(exception); - return null; - } - } - - //gets the type of the statistic from the string, otherwise returns null (param: statName, not case sensitive) - public Statistic.Type getStatType(String statName) { - try { - return Statistic.valueOf(statName.toUpperCase()).getType(); - } - catch (IllegalArgumentException | NullPointerException exception) { - plugin.logStatRelatedExceptions(exception); - return null; - } - } - - //checks if string is a valid statistic (param: statName, not case sensitive) - public boolean isStatistic(String statName) { - return statNames.contains(statName.toLowerCase()); - } - - //checks if string is a valid substatistic dealing with entities (param: statName, not case sensitive) - public boolean isStatEntityType(String statName) { - return entityStatNames.contains(statName.toLowerCase()); - } - - //checks in the most general sense if this statistic is a substatistic (param: statName, not case sensitive) - public boolean isSubStatEntry(String statName) { - return subStatEntryNames.contains(statName.toLowerCase()); - } - - //checks whether a subStatEntry is of the type that the statistic requires - public boolean isMatchingSubStatEntry(String statName, String subStatEntry) { - Statistic.Type type = getStatType(statName); - if (type != null && subStatEntry != null) { - switch (type) { - case ENTITY -> { - return enumHandler.isEntityType(subStatEntry); - } - case ITEM -> { - return enumHandler.isItem(subStatEntry); - } - case BLOCK -> { - return enumHandler.isBlock(subStatEntry); - } - case UNTYPED -> { - return false; - } - } - } - return false; - } - - //returns the names of all general statistics in lowercase - public List getStatNames() { - return statNames; - } - - //returns all statistics that have type entities, in lowercase - public List getEntityTypeNames() { - return entityStatNames; - } - - //returns all substatnames in lowercase - public List getSubStatEntryNames() { - return subStatEntryNames; - } -} diff --git a/src/main/java/com/gmail/artemis/the/gr8/playerstats/StatRequest.java b/src/main/java/com/gmail/artemis/the/gr8/playerstats/StatRequest.java new file mode 100644 index 0000000..64731da --- /dev/null +++ b/src/main/java/com/gmail/artemis/the/gr8/playerstats/StatRequest.java @@ -0,0 +1,67 @@ +package com.gmail.artemis.the.gr8.playerstats; + +import org.bukkit.command.CommandSender; +import org.jetbrains.annotations.NotNull; + +public class StatRequest { + + private final CommandSender sender; + private String statName; + private String subStatEntry; + private String playerName; + private boolean playerFlag; + private boolean topFlag; + + //playerFlag and topFlag are false by default, will be set to true if "player" or "top" is in the args + public StatRequest(@NotNull CommandSender s) { + sender = s; + playerFlag = false; + topFlag = false; + } + + public CommandSender getCommandSender() { + return sender; + } + + public String getStatName() { + return statName; + } + + public void setStatName(String statName) { + this.statName = statName; + } + + public String getSubStatEntry() { + return subStatEntry; + } + + public void setSubStatEntry(String subStatEntry) { + this.subStatEntry = subStatEntry; + } + + public String getPlayerName() { + return playerName; + } + + public void setPlayerName(String playerName) { + this.playerName = playerName; + } + + //the "player" arg in the statCommand is a special case, because it could either be a valid subStatEntry, or indicate that the lookup action should target a specific player + //this is why the playerFlag exists - if this is true, and playerName is null, subStatEntry will be "player" + public boolean playerFlag() { + return playerFlag; + } + + public void setPlayerFlag(boolean playerFlag) { + this.playerFlag = playerFlag; + } + + public boolean topFlag() { + return topFlag; + } + + public void setTopFlag(boolean topFlag) { + this.topFlag = topFlag; + } +} diff --git a/src/main/java/com/gmail/artemis/the/gr8/playerstats/StatThread.java b/src/main/java/com/gmail/artemis/the/gr8/playerstats/StatThread.java new file mode 100644 index 0000000..822b02b --- /dev/null +++ b/src/main/java/com/gmail/artemis/the/gr8/playerstats/StatThread.java @@ -0,0 +1,154 @@ +package com.gmail.artemis.the.gr8.playerstats; + +import com.gmail.artemis.the.gr8.playerstats.utils.EnumHandler; +import com.gmail.artemis.the.gr8.playerstats.utils.OfflinePlayerHandler; +import com.gmail.artemis.the.gr8.playerstats.utils.OutputFormatter; +import org.bukkit.Material; +import org.bukkit.OfflinePlayer; +import org.bukkit.Statistic; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.EntityType; +import org.jetbrains.annotations.NotNull; + +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.stream.Collectors; + +public class StatThread extends Thread { + + private final StatRequest request; + private final EnumHandler enumHandler; + private final OutputFormatter outputFormatter; + private final Main plugin; + private String className = "StatThread"; + + //constructor (called on thread creation) + public StatThread(StatRequest s, EnumHandler e, OutputFormatter o, Main p) { + request = s; + + enumHandler = e; + outputFormatter = o; + plugin = p; + } + + //what the thread will do once started + @Override + public void run() throws IllegalStateException, NullPointerException { + long time = System.currentTimeMillis(); + + if (outputFormatter == null || plugin == null) { + throw new IllegalStateException("Not all classes off the plugin are running!"); + } + if (request == null) { + throw new NullPointerException("No statistic request was found!"); + } + + CommandSender sender = request.getCommandSender(); + String playerName = request.getPlayerName(); + String statName = request.getStatName(); + String subStatEntry = request.getSubStatEntry(); + boolean topFlag = request.topFlag(); + + if (playerName != null) { + try { + sender.sendMessage( + outputFormatter.formatPlayerStat( + playerName, statName, subStatEntry, getStatistic( + statName, subStatEntry, playerName))); + plugin.logTimeTaken(className, "run(): individual stat", time, 60); + + } catch (Exception e) { + sender.sendMessage(outputFormatter.formatExceptions(e.toString())); + } + + } else if (topFlag) { + try { + LinkedHashMap topStats = getTopStatistics(statName, subStatEntry); + plugin.logTimeTaken(className, "run(): for each loop", time, 69); + + String top = outputFormatter.formatTopStats(topStats, statName, subStatEntry); + sender.sendMessage(top); + plugin.logTimeTaken(className, "run(): format output", time, 73); + + } catch (Exception e) { + sender.sendMessage(outputFormatter.formatExceptions(e.toString())); + e.printStackTrace(); + } + } + } + + //returns the integer associated with a certain statistic for a player + private int getStatistic(String statName, String subStatEntryName, String playerName) throws IllegalArgumentException, NullPointerException { + OfflinePlayer player = OfflinePlayerHandler.getOfflinePlayer(playerName); + if (player != null) { + Statistic stat = enumHandler.getStatEnum(statName); + if (stat != null) { + return getPlayerStat(player, stat, subStatEntryName); + } + throw new IllegalArgumentException("Statistic " + statName + " could not be retrieved!"); + } + throw new IllegalArgumentException("Player object for " + playerName + " could not be retrieved!"); + } + + private LinkedHashMap getTopStatistics(String statName, String subStatEntry) { + Statistic stat = enumHandler.getStatEnum(statName); + + if (stat != null) { + HashMap playerStats = new HashMap<>((int) (OfflinePlayerHandler.getOfflinePlayerCount() * 1.05)); + OfflinePlayerHandler.getAllOfflinePlayerNames().forEach(playerName -> { + OfflinePlayer player = OfflinePlayerHandler.getOfflinePlayer(playerName); + if (player != null) + try { + int statistic = getPlayerStat(player, stat, subStatEntry); + if (statistic > 0) { + playerStats.put(playerName, statistic); + } + } catch (IllegalArgumentException ignored) { + } + }); + return playerStats.entrySet().stream() + .sorted(Map.Entry.comparingByValue(Comparator.reverseOrder())) + .limit(10).collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, (e1, e2) -> e1, LinkedHashMap::new)); + } + throw new NullPointerException("Statistic " + statName + " could not be retrieved!"); + } + + private int getPlayerStat(@NotNull OfflinePlayer player, @NotNull Statistic stat, String subStatEntryName) throws IllegalArgumentException { + switch (stat.getType()) { + case UNTYPED -> { + return player.getStatistic(stat); + } + case BLOCK -> { + Material block = enumHandler.getBlock(subStatEntryName); + if (block != null) { + return player.getStatistic(stat, block); + } + else { + throw new IllegalArgumentException(subStatEntryName + " is not a valid block name!"); + } + } + case ENTITY -> { + EntityType entity = enumHandler.getEntityType(subStatEntryName); + if (entity != null) { + return player.getStatistic(stat, entity); + } + else { + throw new IllegalArgumentException(subStatEntryName + " is not a valid entity name!"); + } + } + case ITEM -> { + Material item = enumHandler.getItem(subStatEntryName); + if (item != null) { + return player.getStatistic(stat, item); + } + else { + throw new IllegalArgumentException(subStatEntryName + " is not a valid item name!"); + } + } + default -> + throw new IllegalArgumentException("This statistic does not seem to be of type:untyped/block/entity/item, I think we should panic"); + } + } +} diff --git a/src/main/java/com/gmail/artemis/the/gr8/playerstats/commands/ReloadCommand.java b/src/main/java/com/gmail/artemis/the/gr8/playerstats/commands/ReloadCommand.java index 2702d55..96d2400 100644 --- a/src/main/java/com/gmail/artemis/the/gr8/playerstats/commands/ReloadCommand.java +++ b/src/main/java/com/gmail/artemis/the/gr8/playerstats/commands/ReloadCommand.java @@ -1,6 +1,8 @@ package com.gmail.artemis.the.gr8.playerstats.commands; -import com.gmail.artemis.the.gr8.playerstats.ConfigHandler; +import com.gmail.artemis.the.gr8.playerstats.filehandlers.ConfigHandler; +import com.gmail.artemis.the.gr8.playerstats.Main; +import com.gmail.artemis.the.gr8.playerstats.utils.OfflinePlayerHandler; import com.gmail.artemis.the.gr8.playerstats.utils.OutputFormatter; import org.bukkit.ChatColor; import org.bukkit.command.Command; @@ -12,17 +14,27 @@ public class ReloadCommand implements CommandExecutor { private final ConfigHandler config; private final OutputFormatter outputFormatter; + private final Main plugin; - public ReloadCommand(ConfigHandler c, OutputFormatter o) { + public ReloadCommand(ConfigHandler c, OutputFormatter o, Main p) { outputFormatter = o; config = c; + plugin = p; } @Override public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, String[] args) { if (config.reloadConfig()) { + long time = System.currentTimeMillis(); + outputFormatter.updateOutputColors(); + time = plugin.logTimeTaken("ReloadCommand", "onCommand", time, 33); + + OfflinePlayerHandler.updateOfflinePlayers(); + time = plugin.logTimeTaken("ReloadCommand", "onCommand", time, 36); + sender.sendMessage(ChatColor.GREEN + "Config reloaded!"); + plugin.logTimeTaken("ReloadCommand", "onCommand", time, 39); return true; } return false; diff --git a/src/main/java/com/gmail/artemis/the/gr8/playerstats/commands/StatCommand.java b/src/main/java/com/gmail/artemis/the/gr8/playerstats/commands/StatCommand.java index 5f82ec9..7eb7f29 100644 --- a/src/main/java/com/gmail/artemis/the/gr8/playerstats/commands/StatCommand.java +++ b/src/main/java/com/gmail/artemis/the/gr8/playerstats/commands/StatCommand.java @@ -1,7 +1,9 @@ package com.gmail.artemis.the.gr8.playerstats.commands; import com.gmail.artemis.the.gr8.playerstats.Main; -import com.gmail.artemis.the.gr8.playerstats.StatManager; +import com.gmail.artemis.the.gr8.playerstats.utils.EnumHandler; +import com.gmail.artemis.the.gr8.playerstats.StatRequest; +import com.gmail.artemis.the.gr8.playerstats.StatThread; import com.gmail.artemis.the.gr8.playerstats.utils.OfflinePlayerHandler; import com.gmail.artemis.the.gr8.playerstats.utils.OutputFormatter; import org.bukkit.command.Command; @@ -13,98 +15,81 @@ import org.jetbrains.annotations.NotNull; public class StatCommand implements CommandExecutor { - private final OfflinePlayerHandler offlinePlayerHandler; private final OutputFormatter outputFormatter; - private final StatManager statManager; + private final EnumHandler enumHandler; private final Main plugin; - public StatCommand(OutputFormatter o, StatManager s, Main p) { + public StatCommand(OutputFormatter o, EnumHandler e, Main p) { outputFormatter = o; - statManager = s; + enumHandler = e; plugin = p; - - offlinePlayerHandler = OfflinePlayerHandler.getInstance(); } @Override public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, String[] args) { long time = System.currentTimeMillis(); - long startTime = System.currentTimeMillis(); + //part 1: collecting all relevant information from the args if (args.length >= 2) { + StatRequest request = new StatRequest(sender); - String statName = null; - String subStatEntry = null; - String playerName = null; - boolean playerFlag = false; - - plugin.getLogger().info("onCommand 40: " + (System.currentTimeMillis() - time)); - time = System.currentTimeMillis(); - - //all args are in lowercase for (String arg : args) { - if (statManager.isStatistic(arg)) { - statName = (statName == null) ? arg : statName; - plugin.getLogger().info("onCommand 48: " + (System.currentTimeMillis() - time)); - time = System.currentTimeMillis(); + if (enumHandler.isStatistic(arg) && request.getStatName() == null) { + request.setStatName(arg); } - else if (statManager.isSubStatEntry(arg)) { + else if (enumHandler.isSubStatEntry(arg)) { if (arg.equalsIgnoreCase("player")) { - if (!playerFlag) { - subStatEntry = (subStatEntry == null) ? arg : subStatEntry; - playerFlag = true; - plugin.getLogger().info("onCommand 56: " + (System.currentTimeMillis() - time)); - time = System.currentTimeMillis(); + if (request.playerFlag()) { + if (request.getSubStatEntry() == null) request.setSubStatEntry(arg); + } + else { + request.setPlayerFlag(true); } } + else { - subStatEntry = (subStatEntry == null || playerFlag) ? arg : subStatEntry; - plugin.getLogger().info("onCommand 62: " + (System.currentTimeMillis() - time)); - time = System.currentTimeMillis(); + if (request.getSubStatEntry() == null) request.setSubStatEntry(arg); } } - else if (arg.equalsIgnoreCase("me") && sender instanceof Player) { - playerName = sender.getName(); - plugin.getLogger().info("onCommand 69: " + (System.currentTimeMillis() - time)); - time = System.currentTimeMillis(); + else if (arg.equalsIgnoreCase("top")) { + request.setTopFlag(true); } - else if (offlinePlayerHandler.isOfflinePlayerName(arg)) { - playerName = (playerName == null) ? arg : playerName; - plugin.getLogger().info("onCommand 74: " + (System.currentTimeMillis() - time)); - time = System.currentTimeMillis(); + else if (arg.equalsIgnoreCase("me") && sender instanceof Player) { + request.setPlayerName(sender.getName()); + } + else if (OfflinePlayerHandler.isOfflinePlayerName(arg) && request.getPlayerName() == null) { + request.setPlayerName(arg); } } - if (playerName != null && statName != null) { - plugin.getLogger().info("onCommand 79: " + (System.currentTimeMillis() - time)); - time = System.currentTimeMillis(); - subStatEntry = statManager.isMatchingSubStatEntry(statName, subStatEntry) ? subStatEntry : null; - plugin.getLogger().info("onCommand 82: " + (System.currentTimeMillis() - time)); - time = System.currentTimeMillis(); - try { - plugin.getLogger().info("onCommand 85: " + (System.currentTimeMillis() - time)); - time = System.currentTimeMillis(); - int stat = statManager.getStatistic(statName, subStatEntry, playerName); - plugin.getLogger().info("onCommand 89: " + (System.currentTimeMillis() - time)); - time = System.currentTimeMillis(); - - String msg = outputFormatter.formatPlayerStat(playerName, statName, subStatEntry, stat); - plugin.getLogger().info("onCommand 93: " + (System.currentTimeMillis() - time)); - time = System.currentTimeMillis(); - - sender.sendMessage(msg); - plugin.getLogger().info("onCommand 97: " + (System.currentTimeMillis() - time)); - time = System.currentTimeMillis(); - } - catch (Exception e) { - sender.sendMessage(e.toString()); - } + //part 2: sending the information to the StatThread + if (isValidStatRequest(request)) { + StatThread statThread = new StatThread(request, enumHandler, outputFormatter, plugin); + statThread.start(); + plugin.logTimeTaken("StatCommand", "onCommand", time, 71); + return true; } } - plugin.getLogger().info("onCommand 106: " + (System.currentTimeMillis() - time)); - plugin.getLogger().info("Total time elapsed: " + (System.currentTimeMillis() - startTime)); - return true; + return false; + } + + //check whether all necessary ingredients are present to proceed with a lookup + private boolean isValidStatRequest(StatRequest request) { + if (request.getStatName() != null) { + if (request.topFlag() || request.getPlayerName() != null) { + validatePlayerFlag(request); + return enumHandler.isValidStatEntry(request.getStatName(), request.getSubStatEntry()); + } + } + return false; + } + + //account for the fact that "player" could be either a subStatEntry or a flag to indicate the target for the lookup, and correct the request if necessary + private void validatePlayerFlag(StatRequest request) { + if (!enumHandler.isValidStatEntry(request.getStatName(), request.getSubStatEntry()) && request.playerFlag()) { + request.setSubStatEntry("player"); + } } } diff --git a/src/main/java/com/gmail/artemis/the/gr8/playerstats/commands/TabCompleter.java b/src/main/java/com/gmail/artemis/the/gr8/playerstats/commands/TabCompleter.java index 3ace0a9..29a0f08 100644 --- a/src/main/java/com/gmail/artemis/the/gr8/playerstats/commands/TabCompleter.java +++ b/src/main/java/com/gmail/artemis/the/gr8/playerstats/commands/TabCompleter.java @@ -1,7 +1,6 @@ package com.gmail.artemis.the.gr8.playerstats.commands; import com.gmail.artemis.the.gr8.playerstats.Main; -import com.gmail.artemis.the.gr8.playerstats.StatManager; import com.gmail.artemis.the.gr8.playerstats.utils.EnumHandler; import com.gmail.artemis.the.gr8.playerstats.utils.OfflinePlayerHandler; import org.bukkit.command.Command; @@ -15,16 +14,12 @@ import java.util.stream.Collectors; public class TabCompleter implements org.bukkit.command.TabCompleter { private final EnumHandler enumHandler; - private final OfflinePlayerHandler offlinePlayerHandler; - private final StatManager statManager; private final Main plugin; private final List commandOptions; - public TabCompleter(EnumHandler e, StatManager s, Main p) { + public TabCompleter(EnumHandler e, Main p) { enumHandler = e; - offlinePlayerHandler = OfflinePlayerHandler.getInstance(); - statManager = s; plugin = p; commandOptions = new ArrayList<>(); @@ -45,14 +40,14 @@ public class TabCompleter implements org.bukkit.command.TabCompleter { //after typing "stat", suggest a list of viable statistics if (args.length >= 1) { if (args.length == 1) { - tabSuggestions = statManager.getStatNames().stream().filter(stat -> + tabSuggestions = enumHandler.getStatNames().stream().filter(stat -> stat.contains(args[0].toLowerCase())).collect(Collectors.toList()); } //after checking if args[0] is a viable statistic, suggest substatistic OR commandOptions else { - if (statManager.isStatistic(args[args.length-2])) { - tabSuggestions = switch (statManager.getStatType(args[args.length-2])) { + if (enumHandler.isStatistic(args[args.length-2])) { + tabSuggestions = switch (enumHandler.getStatType(args[args.length-2])) { case UNTYPED -> commandOptions; case BLOCK -> enumHandler.getBlockNames().stream().filter(block -> block.contains(args[args.length - 1])).collect(Collectors.toList()); @@ -61,23 +56,22 @@ public class TabCompleter implements org.bukkit.command.TabCompleter { case ENTITY -> enumHandler.getEntityTypeNames().stream().filter(entity -> entity.contains(args[args.length - 1])).collect(Collectors.toList()); }; - } //if previous arg = "player", suggest playerNames else if (args[args.length-2].equalsIgnoreCase("player")) { - if (args.length >= 3 && statManager.getEntityTypeNames().contains(args[args.length-3].toLowerCase())) { + if (args.length >= 3 && enumHandler.getEntityStatNames().contains(args[args.length-3].toLowerCase())) { tabSuggestions = commandOptions; } else { - tabSuggestions = offlinePlayerHandler.getAllOfflinePlayerNames().stream().filter(player -> + tabSuggestions = OfflinePlayerHandler.getAllOfflinePlayerNames().stream().filter(player -> player.toLowerCase().contains(args[args.length-1].toLowerCase())).collect(Collectors.toList()); } } //after a substatistic, suggest commandOptions - else if (statManager.isSubStatEntry(args[args.length-2])) { + else if (enumHandler.isSubStatEntry(args[args.length-2])) { tabSuggestions = commandOptions; } } diff --git a/src/main/java/com/gmail/artemis/the/gr8/playerstats/ConfigHandler.java b/src/main/java/com/gmail/artemis/the/gr8/playerstats/filehandlers/ConfigHandler.java similarity index 65% rename from src/main/java/com/gmail/artemis/the/gr8/playerstats/ConfigHandler.java rename to src/main/java/com/gmail/artemis/the/gr8/playerstats/filehandlers/ConfigHandler.java index 2bbe46b..172aa18 100644 --- a/src/main/java/com/gmail/artemis/the/gr8/playerstats/ConfigHandler.java +++ b/src/main/java/com/gmail/artemis/the/gr8/playerstats/filehandlers/ConfigHandler.java @@ -1,5 +1,6 @@ -package com.gmail.artemis.the.gr8.playerstats; +package com.gmail.artemis.the.gr8.playerstats.filehandlers; +import com.gmail.artemis.the.gr8.playerstats.Main; import org.bukkit.ChatColor; import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.file.FileConfiguration; @@ -19,24 +20,53 @@ public class ConfigHandler { saveDefaultConfig(); } + //returns the config setting for use-dots, or the default value "true" if no value can be retrieved + public boolean getUseDots() { + ConfigurationSection ranked = config.getConfigurationSection("ranked-list"); + try { + return ranked == null || ranked.getBoolean("use-dots"); + } + catch (Exception e) { + e.printStackTrace(); + return true; + } + } + //returns a HashMap with all the available color choices, or a ChatColor.RESET if no colors were found public HashMap getChatColors() { HashMap chatColors = new HashMap<>(); ConfigurationSection individual = config.getConfigurationSection("individual-statistics"); - chatColors.put("playerNames", getChatColor(individual, "player-names")); - chatColors.put("statNames", getChatColor(individual, "stat-names")); - chatColors.put("subStatNames", getChatColor(individual, "sub-stat-names")); - chatColors.put("numbers", getChatColor(individual, "numbers")); + chatColors.put("player-names", getChatColor(individual, "player-names")); + chatColors.put("stat-names", getChatColor(individual, "stat-names")); + chatColors.put("sub-stat-names", getChatColor(individual, "sub-stat-names")); + chatColors.put("stat-numbers", getChatColor(individual, "stat-numbers")); ConfigurationSection ranked = config.getConfigurationSection("ranked-list"); - chatColors.put("playerNamesRanked", getChatColor(ranked, "player-names")); - chatColors.put("statNamesRanked", getChatColor(ranked, "stat-names")); - chatColors.put("subStatNamesRanked", getChatColor(ranked, "sub-stat-names")); - chatColors.put("numbersRanked", getChatColor(ranked, "numbers")); + chatColors.put("player-names-ranked", getChatColor(ranked, "player-names")); + chatColors.put("list-title", getChatColor(ranked, "list-title")); + chatColors.put("sub-stat-names-ranked", getChatColor(ranked, "sub-stat-names")); + chatColors.put("stat-numbers-ranked", getChatColor(ranked, "stat-numbers")); + chatColors.put("list-numbers", getChatColor(ranked, "list-numbers")); + chatColors.put("dots", getChatColor(ranked, "dots")); return chatColors; } + //reload the config after changes have been made to it + public boolean reloadConfig() { + try { + if (!configFile.exists()) { + saveDefaultConfig(); + } + config = YamlConfiguration.loadConfiguration(configFile); + return true; + } + catch (Exception e) { + e.printStackTrace(); + return false; + } + } + //returns the requested entry from the provided configuration section, null if section does not exist, and ChatColor.RESET if there is no entry private ChatColor getChatColor(ConfigurationSection section, String path) { ChatColor color; @@ -56,21 +86,6 @@ public class ConfigHandler { return color; } - //reload the config after changes have been made to it - public boolean reloadConfig() { - try { - if (!configFile.exists()) { - saveDefaultConfig(); - } - config = YamlConfiguration.loadConfiguration(configFile); - return true; - } - catch (Exception e) { - e.printStackTrace(); - return false; - } - } - //create a config file if none exists yet (from the config.yml in the plugin's resources) private void saveDefaultConfig() { config = plugin.getConfig(); diff --git a/src/main/java/com/gmail/artemis/the/gr8/playerstats/listeners/JoinListener.java b/src/main/java/com/gmail/artemis/the/gr8/playerstats/listeners/JoinListener.java index f489c26..96136cb 100644 --- a/src/main/java/com/gmail/artemis/the/gr8/playerstats/listeners/JoinListener.java +++ b/src/main/java/com/gmail/artemis/the/gr8/playerstats/listeners/JoinListener.java @@ -1,21 +1,20 @@ package com.gmail.artemis.the.gr8.playerstats.listeners; import com.gmail.artemis.the.gr8.playerstats.utils.OfflinePlayerHandler; +import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; import org.bukkit.event.player.PlayerJoinEvent; public class JoinListener implements Listener { - private final OfflinePlayerHandler offlinePlayerHandler; - public JoinListener() { - offlinePlayerHandler = OfflinePlayerHandler.getInstance(); } + @EventHandler public void onPlayerJoin(PlayerJoinEvent joinEvent) { if (!joinEvent.getPlayer().hasPlayedBefore()) { - offlinePlayerHandler.updateOfflinePlayers(); + OfflinePlayerHandler.updateOfflinePlayers(); } } } diff --git a/src/main/java/com/gmail/artemis/the/gr8/playerstats/utils/EnumHandler.java b/src/main/java/com/gmail/artemis/the/gr8/playerstats/utils/EnumHandler.java index 2074569..644bbcc 100644 --- a/src/main/java/com/gmail/artemis/the/gr8/playerstats/utils/EnumHandler.java +++ b/src/main/java/com/gmail/artemis/the/gr8/playerstats/utils/EnumHandler.java @@ -1,32 +1,57 @@ package com.gmail.artemis.the.gr8.playerstats.utils; +import com.gmail.artemis.the.gr8.playerstats.Main; import org.bukkit.Material; +import org.bukkit.Statistic; import org.bukkit.entity.EntityType; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.stream.Collectors; public class EnumHandler { private final List blockNames; private final List entityTypeNames; private final List itemNames; + private final List statNames; + private final List entityStatNames; + private final List subStatEntryNames; + private final Main plugin; - public EnumHandler() { + public EnumHandler(Main p) { + plugin = p; + blockNames = Arrays.stream(Material.values()).filter( Material::isBlock).map(Material::toString).map(String::toLowerCase).toList(); entityTypeNames = Arrays.stream(EntityType.values()).map( EntityType::toString).map(String::toLowerCase).toList(); itemNames = Arrays.stream(Material.values()).filter( Material::isItem).map(Material::toString).map(String::toLowerCase).toList(); + statNames = Arrays.stream(Statistic.values()).map( + Statistic::toString).map(String::toLowerCase).toList(); + + entityStatNames = Arrays.stream(Statistic.values()).filter(statistic -> + statistic.getType().equals(Statistic.Type.ENTITY)).map( + Statistic::toString).map(String::toLowerCase).collect(Collectors.toList()); + + subStatEntryNames = new ArrayList<>(); + subStatEntryNames.addAll(getBlockNames()); + subStatEntryNames.addAll(getEntityTypeNames()); + subStatEntryNames.addAll(getItemNames()); } + //checks whether the provided string is a valid item public boolean isItem(String itemName) { return itemNames.contains(itemName.toLowerCase()); } //returns corresponding item enum constant (uppercase), otherwise null (param: itemName, not case sensitive) + @Nullable public Material getItem(String itemName) { return Material.matchMaterial(itemName); } @@ -36,18 +61,25 @@ public class EnumHandler { return itemNames; } + //checks whether the provided string is a valid entity public boolean isEntityType(String entityName) { return entityTypeNames.contains(entityName.toLowerCase()); } //returns EntityType enum constant (uppercase) if the input name is valid, otherwise null (param: entityName, not case sensitive) + @Nullable public EntityType getEntityType(String entityName) { - EntityType entityType = null; + EntityType entityType; try { entityType = EntityType.valueOf(entityName.toUpperCase()); } - catch (IllegalArgumentException | NullPointerException exception) { - exception.printStackTrace(); + catch (IllegalArgumentException e) { + plugin.getLogger().warning("IllegalArgumentException: " + entityName + " is not a valid statistic name!"); + return null; + } + catch (NullPointerException e) { + plugin.getLogger().warning("NullPointerException: please provide a statistic name!"); + return null; } return entityType; } @@ -57,11 +89,13 @@ public class EnumHandler { return entityTypeNames; } + //checks whether the provided string is a valid block public boolean isBlock(String materialName) { return blockNames.contains(materialName.toLowerCase()); } //returns corresponding block enum constant (uppercase), otherwise null (param: materialName, not case sensitive) + @Nullable public Material getBlock(String materialName) { return Material.matchMaterial(materialName); } @@ -71,4 +105,85 @@ public class EnumHandler { return blockNames; } + //returns the statistic enum constant, or null if non-existent (param: statName, not case sensitive) + public Statistic getStatEnum(String statName) { + try { + return Statistic.valueOf(statName.toUpperCase()); + } + catch (IllegalArgumentException e) { + plugin.getLogger().warning("IllegalArgumentException: " + statName + " is not a valid statistic name!"); + return null; + } + catch (NullPointerException e) { + plugin.getLogger().warning("NullPointerException: please provide a statistic name!"); + return null; + } + } + + //gets the type of the statistic from the string, otherwise returns null (param: statName, not case sensitive) + public Statistic.Type getStatType(String statName) { + try { + return Statistic.valueOf(statName.toUpperCase()).getType(); + } + catch (IllegalArgumentException e) { + plugin.getLogger().warning("IllegalArgumentException: " + statName + " is not a valid statistic name!"); + return null; + } + catch (NullPointerException e) { + plugin.getLogger().warning("NullPointerException: please provide a statistic name!"); + return null; + } + } + + //checks if string is a valid statistic (param: statName, not case sensitive) + public boolean isStatistic(String statName) { + return statNames.contains(statName.toLowerCase()); + } + + //returns the names of all general statistics in lowercase + public List getStatNames() { + return statNames; + } + + //returns all statistics that have type entities, in lowercase + public List getEntityStatNames() { + return entityStatNames; + } + + //checks if this statistic is a subStatEntry, meaning it is a block, item or entity (param: statName, not case sensitive) + public boolean isSubStatEntry(String statName) { + return subStatEntryNames.contains(statName.toLowerCase()); + } + + //checks if string is a valid statistic (param: statName, not case sensitive) + public boolean isValidStatEntry(String statName) { + return isValidStatEntry(statName, null); + } + + //checks whether a subStatEntry is of the type that the statistic requires + public boolean isValidStatEntry(String statName, String subStatEntry) { + Statistic stat = getStatEnum(statName); + return (stat != null && isMatchingSubStatEntry(stat, subStatEntry)); + } + + //returns true if subStatEntry matches the type the stat requires, or if stat is untyped and subStatEntry is null + private boolean isMatchingSubStatEntry(@NotNull Statistic stat, String subStatEntry) { + switch (stat.getType()) { + case ENTITY -> { + return subStatEntry != null && isEntityType(subStatEntry); + } + case ITEM -> { + return subStatEntry != null && isItem(subStatEntry); + } + case BLOCK -> { + return subStatEntry != null && isBlock(subStatEntry); + } + case UNTYPED -> { + return subStatEntry==null; + } + default -> { + return false; + } + } + } } diff --git a/src/main/java/com/gmail/artemis/the/gr8/playerstats/utils/OfflinePlayerHandler.java b/src/main/java/com/gmail/artemis/the/gr8/playerstats/utils/OfflinePlayerHandler.java index b19db79..7cc5fa7 100644 --- a/src/main/java/com/gmail/artemis/the/gr8/playerstats/utils/OfflinePlayerHandler.java +++ b/src/main/java/com/gmail/artemis/the/gr8/playerstats/utils/OfflinePlayerHandler.java @@ -4,51 +4,60 @@ import org.bukkit.Bukkit; import org.bukkit.OfflinePlayer; import java.util.*; -import java.util.stream.Collectors; public class OfflinePlayerHandler { - private static OfflinePlayerHandler instance; - private List offlinePlayers; - private List offlinePlayerNames; - private HashMap offlinePlayerMap; + private static HashMap offlinePlayerMap; + private static List offlinePlayerNames; + private static int totalOfflinePlayers; private OfflinePlayerHandler() { - updateOfflinePlayers(); } - public static OfflinePlayerHandler getInstance() { - if (instance == null) { - instance = new OfflinePlayerHandler(); - } - return instance; - } - - public boolean isOfflinePlayerName(String playerName) { + public static boolean isOfflinePlayerName(String playerName) { return offlinePlayerNames.contains(playerName); } - public OfflinePlayer getOfflinePlayer(String playerName) { - long time = System.currentTimeMillis(); - - OfflinePlayer player = offlinePlayerMap.get(playerName); - System.out.println(("OfflinePlayerHandler 35: " + (System.currentTimeMillis() - time))); - return player; + public static OfflinePlayer getOfflinePlayer(String playerName) { + return offlinePlayerMap.get(playerName); } - public List getAllOfflinePlayers() { - return offlinePlayers; + public static int getOfflinePlayerCount() { + return totalOfflinePlayers > 0 ? totalOfflinePlayers : 1; } - public List getAllOfflinePlayerNames() { + public static List getAllOfflinePlayerNames() { return offlinePlayerNames; } - public void updateOfflinePlayers() { - offlinePlayerMap = new HashMap<>(); - offlinePlayers = Arrays.stream(Bukkit.getOfflinePlayers()).filter(offlinePlayer -> - offlinePlayer.getName() != null && offlinePlayer.hasPlayedBefore()).collect(Collectors.toList()); - offlinePlayerNames = offlinePlayers.stream().map(OfflinePlayer::getName).collect(Collectors.toList()); - offlinePlayers.forEach(offlinePlayer -> offlinePlayerMap.put(offlinePlayer.getName(), offlinePlayer)); + //stores a private HashMap with keys:playerName and values:OfflinePlayer, and a private list of the names for easy access + public static void updateOfflinePlayers() { + long totalTime = System.currentTimeMillis(); + long time = System.currentTimeMillis(); + if (offlinePlayerMap == null) offlinePlayerMap = new HashMap<>(); + else if (!offlinePlayerMap.isEmpty()) { + offlinePlayerMap.clear(); + } + + if (offlinePlayerNames == null) offlinePlayerNames = new ArrayList<>(); + else if (!offlinePlayerNames.isEmpty()) { + offlinePlayerNames.clear(); + } + + Arrays.stream(Bukkit.getOfflinePlayers()).filter(offlinePlayer -> + offlinePlayer.getName() != null && offlinePlayer.hasPlayedBefore()).forEach(offlinePlayer -> { + offlinePlayerNames.add(offlinePlayer.getName()); + offlinePlayerMap.put(offlinePlayer.getName(), offlinePlayer); + }); + System.out.println("OfflinePlayerHandler, making the HashMap and ArrayList: " + (System.currentTimeMillis() - time)); + time = System.currentTimeMillis(); + + totalOfflinePlayers = offlinePlayerMap.size(); + System.out.println("OfflinePlayerHandler, counting the HashMap: " + (System.currentTimeMillis() - time)); + time = System.currentTimeMillis(); + + totalOfflinePlayers = offlinePlayerNames.size(); + System.out.println("OfflinePlayerHandler, counting the ArrayList: " + (System.currentTimeMillis() - time)); + System.out.println("updateOfflinePlayers total time: " + (System.currentTimeMillis() - totalTime)); } } diff --git a/src/main/java/com/gmail/artemis/the/gr8/playerstats/utils/OutputFormatter.java b/src/main/java/com/gmail/artemis/the/gr8/playerstats/utils/OutputFormatter.java index beb4ca6..7cf57a1 100644 --- a/src/main/java/com/gmail/artemis/the/gr8/playerstats/utils/OutputFormatter.java +++ b/src/main/java/com/gmail/artemis/the/gr8/playerstats/utils/OutputFormatter.java @@ -1,51 +1,72 @@ package com.gmail.artemis.the.gr8.playerstats.utils; -import com.gmail.artemis.the.gr8.playerstats.ConfigHandler; -import com.gmail.artemis.the.gr8.playerstats.Main; +import com.gmail.artemis.the.gr8.playerstats.filehandlers.ConfigHandler; import org.bukkit.ChatColor; +import org.bukkit.map.MinecraftFont; -import java.util.HashMap; -import java.util.LinkedHashMap; +import java.util.*; public class OutputFormatter { - //keys for the HashMap are: - //playerNames(Ranked) - //statNames(Ranked) - //subStatNames(Ranked) - //numbers(Ranked) + //keys for the HashMap are the same as the config options (so e.g. player-names/player-names-ranked) + private final ConfigHandler config; private HashMap chatColors; + private final String pluginPrefix; public OutputFormatter(ConfigHandler c) { config = c; + pluginPrefix = ChatColor.GRAY + "[" + ChatColor.GOLD + "PlayerStats" + ChatColor.GRAY + "] " + ChatColor.RESET; updateOutputColors(); } - public String formatTopStats(LinkedHashMap topStats) { - return ""; - } - - public String formatPlayerStat(String playerName, String statName, int stat) { - return formatPlayerStat(playerName, statName, null, stat); + public String formatExceptions(String exception) { + return pluginPrefix + exception; } public String formatPlayerStat(String playerName, String statName, String subStatEntryName, int stat) { - long time = System.currentTimeMillis(); - System.out.println("OutputFormatter 33: " + (System.currentTimeMillis() - time)); - time = System.currentTimeMillis(); - String subStat = subStatEntryName != null ? - chatColors.get("subStatNames") + " (" + subStatEntryName.toLowerCase().replace("_", " ") + ")" : ""; + chatColors.get("sub-stat-names") + " (" + subStatEntryName.toLowerCase().replace("_", " ") + ")" : ""; - System.out.println("OutputFormatter 39: " + (System.currentTimeMillis() - time)); - time = System.currentTimeMillis(); + return chatColors.get("player-names") + playerName + chatColors.get("stat-numbers") + ": " + stat + " " + + chatColors.get("stat-names") + statName.toLowerCase().replace("_", " ") + subStat; + } - String msg = chatColors.get("playerNames") + playerName + chatColors.get("numbers") + ": " + stat + " " + - chatColors.get("statNames") + statName.toLowerCase().replace("_", " ") + subStat; + public String formatTopStats(LinkedHashMap topStats, String statName, String subStatEntryName) { + String subStat = subStatEntryName != null ? + chatColors.get("sub-stat-names-ranked") + " (" + subStatEntryName.toLowerCase().replace("_", " ") + ")" : ""; + String topCount = chatColors.get("list-numbers") + " " + topStats.size(); + String title = "\n" + pluginPrefix + chatColors.get("list-title") + "Top" + topCount + chatColors.get("list-title") + " " + + statName.toLowerCase().replace("_", " ") + subStat; - System.out.println("OutputFormatter 45: " + (System.currentTimeMillis() - time)); - return msg; + boolean useDots = config.getUseDots(); + int count = 0; + Set playerNames = topStats.keySet(); + MinecraftFont font = new MinecraftFont(); + + StringBuilder rankList = new StringBuilder(); + for (String playerName : playerNames) { + count = count+1; + + rankList.append("\n") + .append(chatColors.get("list-numbers")).append(count).append(". ") + .append(chatColors.get("player-names-ranked")).append(playerName) + .append(chatColors.get("dots")); + + if (useDots) { + rankList.append(" "); + int dots = (int) Math.round((125.0 - font.getWidth(count + ". " + playerName))/2); + if (dots >= 1) { + rankList.append(".".repeat(dots)); + } + } + else { + rankList.append(":"); + } + + rankList.append(" ").append(chatColors.get("stat-numbers-ranked")).append(topStats.get(playerName).toString()); + } + return title + rankList; } public void updateOutputColors() { diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index e1a083c..2fc8f81 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -1,15 +1,23 @@ # PlayerStats Configuration -# --- Color Options --- -# supports: all default Minecraft colors +# --- General Options --- + + +# --- Format & Color Options --- individual-statistics: player-names: gold stat-names: yellow sub-stat-names: yellow - numbers: white + stat-numbers: white ranked-list: - player-names: gold - stat-names: yellow + player-names: green + list-title: yellow sub-stat-names: yellow - numbers: white \ No newline at end of file + stat-numbers: white + list-numbers: gold + +# If true, the statistics will be aligned so that they are all underneath each other + use-dots: true + dots: dark_gray +