Add configurable score buckets

This commit is contained in:
2026-09-26 18:18:00 -07:00
parent 46c93685ad
commit 1773361716
6 changed files with 367 additions and 30 deletions

View File

@@ -9,12 +9,17 @@
* `/score-subtract [playerName] <amount>` - 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 <id> <singular> <plural> <initialValue>` - Creates a bucket (operators).
* `/score-bucket <playerName|all> <bucketId>` - 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.

View File

@@ -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. |

View File

@@ -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 <https://www.gnu.org/licenses/agpl-3.0.txt>.
*/
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;
}
}

View File

@@ -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<UUID, Integer> _playerScores = new HashMap<UUID, Integer>();
private final Map<String, ScoreBucket> _buckets = new LinkedHashMap<>();
private final Map<String, Map<UUID, Integer>> _bucketScores = new LinkedHashMap<>();
private final Map<UUID, String> _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<String, ScoreBucket> 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<UUID, Integer> 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

View File

@@ -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 <https://www.gnu.org/licenses/agpl-3.0.txt>.
*/
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 <id> <singular> <plural> <initialValue>"
+ " | <player|all> <bucketId>")
.color(NamedTextColor.YELLOW));
}
}

View File

@@ -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 <id> <singular> <plural> <initialValue> | <player|all> <bucketId>
permissions:
scorekeeper.admin:
description: Allows managing score buckets.
default: op