diff --git a/README.md b/README.md index b39e92d..3010144 100644 --- a/README.md +++ b/README.md @@ -9,12 +9,17 @@ * `/score-subtract [playerName] ` - Subtracts amount points from the player's score. * `/score-reset [playerName]` - Resets the player's score to 0. * `/score-archive [playerName]` - Saves the player's score to a "High Scores" table and resets their current score to 0. +* `/score-bucket [bucketId]` - Switches your active bucket. +* `/score-bucket create ` - Creates a bucket (operators). +* `/score-bucket ` - Switches a player or the server default (operators). NOTES: * All commands will use the executing player in place of [playerName] if it is omitted. * Permissions support is coming AFTER I get archive to work. * The commands aside from get are intended for admins and whatnot as some other plugin should be using the methods instead of letting player's execute commands. +Other plugins can create buckets with `createBucket(id, singular, plural, initialValue)` and use the bucket-aware score methods. Existing score methods operate on the player's active bucket. Bucket definitions, assignments, and scores are saved in `plugins/ScoreKeeper/scores.yml`. + ## Development See [CONTRIBUTING.md](CONTRIBUTING.md) for development environment setup instructions. diff --git a/TODO.md b/TODO.md index 0ee50cc..95a891d 100644 --- a/TODO.md +++ b/TODO.md @@ -21,7 +21,7 @@ | ID | Status | Title | |:-----:|:--------:|:------| | SK-01 | Done | Save scores between server launches. | -| SK-02 | Ready | Allow tracking scores by bucket. An admin or other plugin should be able to create types of scores with named other than points. { id: "Bread", singular: "loaf", plural: "loaves", initialValue: 0 } Each player's score will be tracked in a bucket by id. Players may be all switched to a bucket or individually switched. |\ +| SK-02 | Done | Allow tracking scores by bucket. An admin or other plugin should be able to create types of scores with named other than points. { id: "Bread", singular: "loaf", plural: "loaves", initialValue: 0 } Each player's score will be tracked in a bucket by id. Players may be all switched to a bucket or individually switched. |\ | SK-03 | Ready | Add optional score reporting to buckets with these four values. { id: "Bread", singular: "loaf", plural: "loaves", initialValue: 0, reporting:"admin|global|player|none" } admin means ops/admin only, global means everyone on the server, player means only tell the player, and none means don't report changes. | SK-04 | Ready | Tell a player when their bucket changes. Something like "You are now tracking ${id}. You have ${score} ${plural or singular depending in score}." | | SK-05 | Ready | Enable high score runs for a bucket. Admins or pugins should be able to start a run on a certain kind of score. The top n (configurable) players will have their scores reported globally for this bucket and the individual player will have their score reported to them when it changes. Scores will be optionally reset to their initialValue with the old score for this bucket saved. High score runs will have a lifetime of a target high score, a time like 4 hours or 1 week, or until-disabled. When the high score run is finished all players will be messaged a table with the top n places and their scores along with the individual user's place and score. The scores will be reset to their previous values if they were reset to their initialValue at the beginning otherwise they will be left alone. | diff --git a/src/main/java/com/majinnaibu/minecraft/plugins/scorekeeper/ScoreBucket.java b/src/main/java/com/majinnaibu/minecraft/plugins/scorekeeper/ScoreBucket.java new file mode 100644 index 0000000..8ba1f85 --- /dev/null +++ b/src/main/java/com/majinnaibu/minecraft/plugins/scorekeeper/ScoreBucket.java @@ -0,0 +1,57 @@ +/* +This file is part of ScoreKeeper. + +ScoreKeeper is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +ScoreKeeper is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with ScoreKeeper. If not, see . +*/ + +package com.majinnaibu.minecraft.plugins.scorekeeper; + +public final class ScoreBucket { + private final String _id; + private final String _singular; + private final String _plural; + private final int _initialValue; + + public ScoreBucket(String id, String singular, String plural, int initialValue) { + if (id == null || !id.matches("[A-Za-z0-9_-]+")) { + throw new IllegalArgumentException("Bucket ID must contain only letters, numbers, _ or -"); + } + if (singular == null || singular.isBlank()) { + throw new IllegalArgumentException("Bucket singular label cannot be blank"); + } + if (plural == null || plural.isBlank()) { + throw new IllegalArgumentException("Bucket plural label cannot be blank"); + } + _id = id; + _singular = singular; + _plural = plural; + _initialValue = initialValue; + } + + public String getId() { + return _id; + } + + public String getSingular() { + return _singular; + } + + public String getPlural() { + return _plural; + } + + public int getInitialValue() { + return _initialValue; + } +} diff --git a/src/main/java/com/majinnaibu/minecraft/plugins/scorekeeper/ScoreKeeperPlugin.java b/src/main/java/com/majinnaibu/minecraft/plugins/scorekeeper/ScoreKeeperPlugin.java index f84aefc..28abf76 100644 --- a/src/main/java/com/majinnaibu/minecraft/plugins/scorekeeper/ScoreKeeperPlugin.java +++ b/src/main/java/com/majinnaibu/minecraft/plugins/scorekeeper/ScoreKeeperPlugin.java @@ -19,12 +19,14 @@ package com.majinnaibu.minecraft.plugins.scorekeeper; import java.io.File; import java.io.IOException; -import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; import java.util.UUID; import java.util.logging.Level; import com.majinnaibu.minecraft.plugins.scorekeeper.commands.ScoreAddCommand; import com.majinnaibu.minecraft.plugins.scorekeeper.commands.ScoreArchiveCommand; +import com.majinnaibu.minecraft.plugins.scorekeeper.commands.ScoreBucketCommand; import com.majinnaibu.minecraft.plugins.scorekeeper.commands.ScoreGetCommand; import com.majinnaibu.minecraft.plugins.scorekeeper.commands.ScoreResetCommand; import com.majinnaibu.minecraft.plugins.scorekeeper.commands.ScoreSubtractCommand; @@ -33,12 +35,16 @@ import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.command.CommandSender; +import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.entity.Player; import org.bukkit.plugin.java.JavaPlugin; public class ScoreKeeperPlugin extends JavaPlugin { - private final HashMap _playerScores = new HashMap(); + private final Map _buckets = new LinkedHashMap<>(); + private final Map> _bucketScores = new LinkedHashMap<>(); + private final Map _playerBuckets = new LinkedHashMap<>(); + private String _defaultBucketId = "points"; public final String _logPrefix = "[ScoreKeeper] "; public final Component _messagePrefix = Component.text("[") @@ -48,8 +54,19 @@ public class ScoreKeeperPlugin extends JavaPlugin { @Override public void onDisable() { YamlConfiguration scores = new YamlConfiguration(); - for (var entry : _playerScores.entrySet()) { - scores.set(entry.getKey().toString(), entry.getValue()); + scores.set("version", 2); + scores.set("default-bucket", _defaultBucketId); + for (ScoreBucket bucket : _buckets.values()) { + String path = "buckets." + bucket.getId(); + scores.set(path + ".singular", bucket.getSingular()); + scores.set(path + ".plural", bucket.getPlural()); + scores.set(path + ".initial-value", bucket.getInitialValue()); + for (var entry : _bucketScores.get(bucket.getId()).entrySet()) { + scores.set(path + ".scores." + entry.getKey(), entry.getValue()); + } + } + for (var entry : _playerBuckets.entrySet()) { + scores.set("player-buckets." + entry.getKey(), entry.getValue()); } try { scores.save(new File(getDataFolder(), "scores.yml")); @@ -65,18 +82,11 @@ public class ScoreKeeperPlugin extends JavaPlugin { getCommand("score-subtract").setExecutor(new ScoreSubtractCommand(this)); getCommand("score-reset").setExecutor(new ScoreResetCommand(this)); getCommand("score-archive").setExecutor(new ScoreArchiveCommand(this)); + getCommand("score-bucket").setExecutor(new ScoreBucketCommand(this)); - File scoreFile = new File(getDataFolder(), "scores.yml"); - if (scoreFile.isFile()) { - YamlConfiguration scores = YamlConfiguration.loadConfiguration(scoreFile); - for (String key : scores.getKeys(false)) { - try { - _playerScores.put(UUID.fromString(key), scores.getInt(key)); - } catch (IllegalArgumentException ex) { - logWarning("Ignoring score with invalid player UUID: " + key); - } - } - } + _buckets.put("points", new ScoreBucket("points", "point", "points", 0)); + _bucketScores.put("points", new LinkedHashMap<>()); + loadScores(); logInfo( getPluginMeta().getName() + " version " + getPluginMeta().getVersion() + " is enabled."); @@ -84,8 +94,11 @@ public class ScoreKeeperPlugin extends JavaPlugin { // region Commands public void addScore(Player player, int amount) { - int oldScore = getPlayerScore(player); - setPlayerScore(player, oldScore + amount); + addScore(player, getPlayerBucket(player), amount); + } + + public void addScore(Player player, String bucketId, int amount) { + setScore(player, bucketId, getScore(player, bucketId) + amount); } public void archiveScore(Player player) { @@ -93,20 +106,77 @@ public class ScoreKeeperPlugin extends JavaPlugin { } public int getScore(Player player) { - return getPlayerScore(player); + return getScore(player, getPlayerBucket(player)); + } + + public int getScore(Player player, String bucketId) { + ScoreBucket bucket = requireBucket(bucketId); + return _bucketScores + .get(bucketId) + .computeIfAbsent(player.getUniqueId(), uuid -> bucket.getInitialValue()); } public void resetScore(Player player) { - setPlayerScore(player, 0); + resetScore(player, getPlayerBucket(player)); + } + + public void resetScore(Player player, String bucketId) { + setScore(player, bucketId, requireBucket(bucketId).getInitialValue()); } public void setScore(Player player, int score) { - setPlayerScore(player, score); + setScore(player, getPlayerBucket(player), score); + } + + public void setScore(Player player, String bucketId, int score) { + requireBucket(bucketId); + _bucketScores.get(bucketId).put(player.getUniqueId(), score); } public void subtractScore(Player player, int amount) { - int oldScore = getPlayerScore(player); - setPlayerScore(player, oldScore - amount); + subtractScore(player, getPlayerBucket(player), amount); + } + + public void subtractScore(Player player, String bucketId, int amount) { + setScore(player, bucketId, getScore(player, bucketId) - amount); + } + + public ScoreBucket createBucket(String id, String singular, String plural, int initialValue) { + ScoreBucket bucket = new ScoreBucket(id, singular, plural, initialValue); + if (_buckets.containsKey(id)) { + throw new IllegalArgumentException("A score bucket with ID " + id + " already exists"); + } + _buckets.put(id, bucket); + _bucketScores.put(id, new LinkedHashMap<>()); + return bucket; + } + + public ScoreBucket getBucket(String bucketId) { + return _buckets.get(bucketId); + } + + public Map getBuckets() { + return Map.copyOf(_buckets); + } + + public String getPlayerBucket(Player player) { + return _playerBuckets.getOrDefault(player.getUniqueId(), _defaultBucketId); + } + + public void switchPlayerBucket(Player player, String bucketId) { + requireBucket(bucketId); + UUID playerId = player.getUniqueId(); + if (bucketId.equals(_defaultBucketId)) { + _playerBuckets.remove(playerId); + } else { + _playerBuckets.put(playerId, bucketId); + } + } + + public void switchAllPlayers(String bucketId) { + requireBucket(bucketId); + _defaultBucketId = bucketId; + _playerBuckets.clear(); } // endregion @@ -116,16 +186,84 @@ public class ScoreKeeperPlugin extends JavaPlugin { reciever.sendMessage(_messagePrefix.append(message)); } - private int getPlayerScore(Player player) { - UUID uuid = player.getUniqueId(); - if (!_playerScores.containsKey(uuid)) { - _playerScores.put(uuid, 0); + private ScoreBucket requireBucket(String bucketId) { + ScoreBucket bucket = _buckets.get(bucketId); + if (bucket == null) { + throw new IllegalArgumentException("Unknown score bucket: " + bucketId); } - return _playerScores.get(uuid); + return bucket; } - private void setPlayerScore(Player player, int score) { - _playerScores.put(player.getUniqueId(), score); + private void loadScores() { + File scoreFile = new File(getDataFolder(), "scores.yml"); + if (!scoreFile.isFile()) { + return; + } + + YamlConfiguration scores = YamlConfiguration.loadConfiguration(scoreFile); + ConfigurationSection bucketSection = scores.getConfigurationSection("buckets"); + if (bucketSection == null) { + loadLegacyScores(scores); + return; + } + + _buckets.clear(); + _bucketScores.clear(); + for (String id : bucketSection.getKeys(false)) { + try { + createBucket( + id, + bucketSection.getString(id + ".singular", id), + bucketSection.getString(id + ".plural", id), + bucketSection.getInt(id + ".initial-value")); + } catch (IllegalArgumentException ex) { + logWarning("Ignoring invalid score bucket " + id + ": " + ex.getMessage()); + } + } + if (!_buckets.containsKey("points")) { + createBucket("points", "point", "points", 0); + } + _defaultBucketId = scores.getString("default-bucket", "points"); + if (!_buckets.containsKey(_defaultBucketId)) { + logWarning("Unknown default score bucket; using points instead."); + _defaultBucketId = "points"; + } + + for (String id : _buckets.keySet()) { + ConfigurationSection bucketScores = + scores.getConfigurationSection("buckets." + id + ".scores"); + if (bucketScores != null) { + loadPlayerScores(bucketScores, _bucketScores.get(id)); + } + } + ConfigurationSection playerBuckets = scores.getConfigurationSection("player-buckets"); + if (playerBuckets != null) { + for (String key : playerBuckets.getKeys(false)) { + try { + UUID playerId = UUID.fromString(key); + String bucketId = playerBuckets.getString(key); + if (_buckets.containsKey(bucketId) && !bucketId.equals(_defaultBucketId)) { + _playerBuckets.put(playerId, bucketId); + } + } catch (IllegalArgumentException ex) { + logWarning("Ignoring invalid player bucket UUID: " + key); + } + } + } + } + + private void loadLegacyScores(YamlConfiguration scores) { + loadPlayerScores(scores, _bucketScores.get("points")); + } + + private void loadPlayerScores(ConfigurationSection scores, Map target) { + for (String key : scores.getKeys(false)) { + try { + target.put(UUID.fromString(key), scores.getInt(key)); + } catch (IllegalArgumentException ex) { + logWarning("Ignoring score with invalid player UUID: " + key); + } + } } // endregion diff --git a/src/main/java/com/majinnaibu/minecraft/plugins/scorekeeper/commands/ScoreBucketCommand.java b/src/main/java/com/majinnaibu/minecraft/plugins/scorekeeper/commands/ScoreBucketCommand.java new file mode 100644 index 0000000..225e9fc --- /dev/null +++ b/src/main/java/com/majinnaibu/minecraft/plugins/scorekeeper/commands/ScoreBucketCommand.java @@ -0,0 +1,130 @@ +/* +This file is part of ScoreKeeper. + +ScoreKeeper is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +ScoreKeeper is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with ScoreKeeper. If not, see . +*/ + +package com.majinnaibu.minecraft.plugins.scorekeeper.commands; + +import com.majinnaibu.minecraft.plugins.scorekeeper.ScoreBucket; +import com.majinnaibu.minecraft.plugins.scorekeeper.ScoreKeeperPlugin; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; + +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +public class ScoreBucketCommand implements CommandExecutor { + private final ScoreKeeperPlugin _plugin; + + public ScoreBucketCommand(ScoreKeeperPlugin plugin) { + _plugin = plugin; + } + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + if (args.length == 0) { + if (sender instanceof Player player) { + String bucketId = _plugin.getPlayerBucket(player); + ScoreBucket bucket = _plugin.getBucket(bucketId); + _plugin.sendMessage( + sender, + Component.text( + "You are tracking " + bucket.getId() + " (" + bucket.getPlural() + ").")); + } else { + sendUsage(sender); + } + return true; + } + + if (args.length == 5 && args[0].equalsIgnoreCase("create")) { + if (!requireAdmin(sender)) { + return true; + } + try { + _plugin.createBucket(args[1], args[2], args[3], Integer.parseInt(args[4])); + _plugin.sendMessage(sender, Component.text("Created score bucket " + args[1] + ".")); + } catch (IllegalArgumentException ex) { + sendError(sender, ex.getMessage()); + } + return true; + } + + if (args.length == 1 && sender instanceof Player player) { + switchPlayerBucket(sender, player, args[0]); + return true; + } + + if (args.length == 2) { + if (!requireAdmin(sender)) { + return true; + } + if (args[0].equalsIgnoreCase("all")) { + try { + _plugin.switchAllPlayers(args[1]); + _plugin.sendMessage( + sender, Component.text("All players are now tracking " + args[1] + ".")); + } catch (IllegalArgumentException ex) { + sendError(sender, ex.getMessage()); + } + return true; + } + + Player target = _plugin.getServer().getPlayerExact(args[0]); + if (target == null) { + sendError(sender, "Can't find a player with that name"); + } else { + switchPlayerBucket(sender, target, args[1]); + } + return true; + } + + sendUsage(sender); + return true; + } + + private void switchPlayerBucket(CommandSender sender, Player target, String bucketId) { + try { + _plugin.switchPlayerBucket(target, bucketId); + _plugin.sendMessage( + sender, Component.text(target.getName() + " is now tracking " + bucketId + ".")); + } catch (IllegalArgumentException ex) { + sendError(sender, ex.getMessage()); + } + } + + private boolean requireAdmin(CommandSender sender) { + if (sender.hasPermission("scorekeeper.admin")) { + return true; + } + sendError(sender, "You do not have permission to manage score buckets"); + return false; + } + + private void sendError(CommandSender sender, String message) { + _plugin.sendMessage(sender, Component.text(message).color(NamedTextColor.RED)); + } + + private void sendUsage(CommandSender sender) { + _plugin.sendMessage( + sender, + Component.text( + "Usage: /score-bucket [bucketId] | create " + + " | ") + .color(NamedTextColor.YELLOW)); + } +} diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 0555cec..d3c8f2d 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -18,4 +18,11 @@ commands: score-archive: description: Archives and resets the player's score usage: /score-archive [playername] + score-bucket: + description: Selects or manages score buckets. + usage: /score-bucket [bucketId] | create | +permissions: + scorekeeper.admin: + description: Allows managing score buckets. + default: op \ No newline at end of file