Add persistent score archives

This commit is contained in:
2026-09-26 18:38:41 -07:00
parent 0e84cb1b0f
commit 60e4523ff8
6 changed files with 333 additions and 5 deletions

View File

@@ -21,6 +21,7 @@ import java.io.File;
import java.io.IOException;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
@@ -31,6 +32,7 @@ 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.ScoreArchiveListCommand;
import com.majinnaibu.minecraft.plugins.scorekeeper.commands.ScoreBucketCommand;
import com.majinnaibu.minecraft.plugins.scorekeeper.commands.ScoreGetCommand;
import com.majinnaibu.minecraft.plugins.scorekeeper.commands.ScoreResetCommand;
@@ -51,6 +53,7 @@ public class ScoreKeeperPlugin extends JavaPlugin {
private final Map<String, Map<UUID, Integer>> _bucketScores = new LinkedHashMap<>();
private final Map<UUID, String> _playerBuckets = new LinkedHashMap<>();
private final Map<String, HighScoreRun> _highScoreRuns = new LinkedHashMap<>();
private final Map<String, ScoreArchive> _scoreArchives = new LinkedHashMap<>();
private String _defaultBucketId = "points";
public final String _logPrefix = "[ScoreKeeper] ";
public final Component _messagePrefix =
@@ -92,6 +95,7 @@ public class ScoreKeeperPlugin extends JavaPlugin {
} catch (IOException ex) {
logError(ex);
}
saveArchives();
}
@Override
@@ -101,12 +105,14 @@ 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-archive-list").setExecutor(new ScoreArchiveListCommand(this));
getCommand("score-bucket").setExecutor(new ScoreBucketCommand(this));
getCommand("score-run").setExecutor(new ScoreRunCommand(this));
_buckets.put("points", new ScoreBucket("points", "point", "points", 0));
_bucketScores.put("points", new LinkedHashMap<>());
loadScores();
loadArchives();
checkHighScoreRuns();
getServer().getScheduler().runTaskTimer(this, this::checkHighScoreRuns, 20L, 20L);
@@ -124,7 +130,30 @@ public class ScoreKeeperPlugin extends JavaPlugin {
}
public void archiveScore(Player player) {
logWarning("Unable to archive score for " + player.getName() + ".");
archivePlayerScore(player);
}
public String archivePlayerScore(Player player) {
String bucketId = getPlayerBucket(player);
return createScoreArchive(
"player " + player.getName() + " in " + bucketId, List.of(bucketId), player);
}
public String archiveBucketScores(String bucketId) {
requireBucket(bucketId);
return createScoreArchive("bucket " + bucketId, List.of(bucketId), null);
}
public String archiveAllScores() {
return createScoreArchive("all buckets", new ArrayList<>(_buckets.keySet()), null);
}
public List<ScoreArchive> getScoreArchives() {
return List.copyOf(_scoreArchives.values());
}
public ScoreArchive getScoreArchive(String archiveId) {
return _scoreArchives.get(archiveId);
}
public int getScore(Player player) {
@@ -298,6 +327,35 @@ public class ScoreKeeperPlugin extends JavaPlugin {
"You are now tracking " + bucket.getId() + ". You have " + score + " " + unit + "."));
}
private String createScoreArchive(String scope, List<String> bucketIds, Player player) {
for (String bucketId : bucketIds) {
if (_highScoreRuns.containsKey(bucketId)) {
throw new IllegalArgumentException(
"Stop the active high-score run for " + bucketId + " before archiving it");
}
}
Map<String, Map<UUID, Integer>> archivedScores = new LinkedHashMap<>();
UUID playerId = player == null ? null : player.getUniqueId();
for (String bucketId : bucketIds) {
ScoreBucket bucket = requireBucket(bucketId);
Map<UUID, Integer> bucketScores = _bucketScores.get(bucketId);
if (playerId == null) {
archivedScores.put(bucketId, new LinkedHashMap<>(bucketScores));
bucketScores.replaceAll((uuid, score) -> bucket.getInitialValue());
} else {
int score = getScore(player, bucketId);
archivedScores.put(bucketId, Map.of(playerId, score));
bucketScores.put(playerId, bucket.getInitialValue());
}
}
String archiveId = UUID.randomUUID().toString();
_scoreArchives.put(
archiveId, new ScoreArchive(archiveId, scope, System.currentTimeMillis(), archivedScores));
return archiveId;
}
private void startHighScoreRun(
String bucketId,
int topN,
@@ -514,6 +572,62 @@ public class ScoreKeeperPlugin extends JavaPlugin {
loadHighScoreRuns(scores);
}
private void loadArchives() {
File archiveFile = new File(getDataFolder(), "archives.yml");
if (!archiveFile.isFile()) {
return;
}
YamlConfiguration archives = YamlConfiguration.loadConfiguration(archiveFile);
ConfigurationSection archiveSection = archives.getConfigurationSection("archives");
if (archiveSection == null) {
return;
}
for (String archiveId : archiveSection.getKeys(false)) {
String path = "archives." + archiveId;
Map<String, Map<UUID, Integer>> bucketScores = new LinkedHashMap<>();
ConfigurationSection buckets = archives.getConfigurationSection(path + ".buckets");
if (buckets != null) {
for (String bucketId : buckets.getKeys(false)) {
Map<UUID, Integer> playerScores = new LinkedHashMap<>();
ConfigurationSection savedScores =
archives.getConfigurationSection(path + ".buckets." + bucketId + ".scores");
if (savedScores != null) {
loadPlayerScores(savedScores, playerScores);
}
bucketScores.put(bucketId, playerScores);
}
}
_scoreArchives.put(
archiveId,
new ScoreArchive(
archiveId,
archives.getString(path + ".scope", "unknown"),
archives.getLong(path + ".created-at"),
bucketScores));
}
}
private void saveArchives() {
YamlConfiguration archives = new YamlConfiguration();
for (ScoreArchive archive : _scoreArchives.values()) {
String path = "archives." + archive.getId();
archives.set(path + ".scope", archive.getScope());
archives.set(path + ".created-at", archive.getCreatedAtMillis());
for (var bucketEntry : archive.getBucketScores().entrySet()) {
for (var scoreEntry : bucketEntry.getValue().entrySet()) {
archives.set(
path + ".buckets." + bucketEntry.getKey() + ".scores." + scoreEntry.getKey(),
scoreEntry.getValue());
}
}
}
try {
archives.save(new File(getDataFolder(), "archives.yml"));
} catch (IOException ex) {
logError(ex);
}
}
private void loadLegacyScores(YamlConfiguration scores) {
loadPlayerScores(scores, _bucketScores.get("points"));
}
@@ -638,6 +752,44 @@ public class ScoreKeeperPlugin extends JavaPlugin {
private record ScoreEntry(UUID playerId, String playerName, int score) {}
public static final class ScoreArchive {
private final String _id;
private final String _scope;
private final long _createdAtMillis;
private final Map<String, Map<UUID, Integer>> _bucketScores;
private ScoreArchive(
String id,
String scope,
long createdAtMillis,
Map<String, Map<UUID, Integer>> bucketScores) {
_id = id;
_scope = scope;
_createdAtMillis = createdAtMillis;
Map<String, Map<UUID, Integer>> copiedScores = new LinkedHashMap<>();
for (var entry : bucketScores.entrySet()) {
copiedScores.put(entry.getKey(), Map.copyOf(entry.getValue()));
}
_bucketScores = Collections.unmodifiableMap(copiedScores);
}
public String getId() {
return _id;
}
public String getScope() {
return _scope;
}
public long getCreatedAtMillis() {
return _createdAtMillis;
}
public Map<String, Map<UUID, Integer>> getBucketScores() {
return _bucketScores;
}
}
// endregion
// region Logging

View File

@@ -20,10 +20,12 @@ package com.majinnaibu.minecraft.plugins.scorekeeper.commands;
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 ScoreArchiveCommand implements CommandExecutor {
private final ScoreKeeperPlugin _plugin;
@@ -34,7 +36,45 @@ public class ScoreArchiveCommand implements CommandExecutor {
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] split) {
_plugin.sendMessage(sender, Component.text("archive command unimplemented"));
if (!sender.hasPermission("scorekeeper.admin")) {
sendError(sender, "You do not have permission to archive scores");
return true;
}
try {
String archiveId;
if (split.length == 1 && split[0].equalsIgnoreCase("all")) {
archiveId = _plugin.archiveAllScores();
} else if (split.length == 2 && split[0].equalsIgnoreCase("bucket")) {
archiveId = _plugin.archiveBucketScores(split[1]);
} else if (split.length == 0 && sender instanceof Player player) {
archiveId = _plugin.archivePlayerScore(player);
} else if (split.length == 1) {
Player target = _plugin.getServer().getPlayerExact(split[0]);
if (target == null) {
sendError(sender, "Can't find a player with that name");
return true;
}
archiveId = _plugin.archivePlayerScore(target);
} else {
sendUsage(sender);
return true;
}
_plugin.sendMessage(sender, Component.text("Saved score archive " + archiveId + "."));
} catch (IllegalArgumentException ex) {
sendError(sender, ex.getMessage());
}
return true;
}
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-archive [playerName|all] | /score-archive bucket <bucketId>")
.color(NamedTextColor.YELLOW));
}
}

View File

@@ -0,0 +1,131 @@
/*
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 java.time.Instant;
import java.util.Comparator;
import java.util.Map;
import java.util.UUID;
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.OfflinePlayer;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
public class ScoreArchiveListCommand implements CommandExecutor {
private final ScoreKeeperPlugin _plugin;
public ScoreArchiveListCommand(ScoreKeeperPlugin plugin) {
_plugin = plugin;
}
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (args.length == 0) {
var archives = _plugin.getScoreArchives();
if (archives.isEmpty()) {
_plugin.sendMessage(sender, Component.text("No score archives have been saved."));
} else {
archives.stream()
.sorted(
Comparator.comparingLong(
(ScoreKeeperPlugin.ScoreArchive archive) -> archive.getCreatedAtMillis())
.reversed())
.forEach(
archive ->
_plugin.sendMessage(
sender,
Component.text(
archive.getId()
+ " | "
+ archive.getScope()
+ " | "
+ Instant.ofEpochMilli(archive.getCreatedAtMillis()))));
}
return true;
}
if (args.length != 1) {
sendUsage(sender);
return true;
}
ScoreKeeperPlugin.ScoreArchive archive = _plugin.getScoreArchive(args[0]);
if (archive == null) {
_plugin.sendMessage(
sender,
Component.text("No score archive exists with ID " + args[0] + ".")
.color(NamedTextColor.RED));
return true;
}
_plugin.sendMessage(
sender,
Component.text(
"Archive "
+ archive.getId()
+ " ("
+ archive.getScope()
+ ", "
+ Instant.ofEpochMilli(archive.getCreatedAtMillis())
+ ")"));
for (var bucketEntry : archive.getBucketScores().entrySet()) {
ScoreBucket bucket = _plugin.getBucket(bucketEntry.getKey());
_plugin.sendMessage(sender, Component.text("Bucket " + bucketEntry.getKey() + ":"));
bucketEntry.getValue().entrySet().stream()
.sorted(
Map.Entry.<UUID, Integer>comparingByValue()
.reversed()
.thenComparing(entry -> entry.getKey().toString()))
.forEach(
entry ->
_plugin.sendMessage(
sender,
Component.text(
displayName(entry.getKey())
+ " - "
+ entry.getValue()
+ " "
+ scoreUnit(bucket, entry.getValue()))));
}
return true;
}
private String displayName(UUID playerId) {
OfflinePlayer player = _plugin.getServer().getOfflinePlayer(playerId);
return player.getName() == null ? playerId.toString() : player.getName();
}
private String scoreUnit(ScoreBucket bucket, int score) {
if (bucket == null) {
return "scores";
}
return score == 1 ? bucket.getSingular() : bucket.getPlural();
}
private void sendUsage(CommandSender sender) {
_plugin.sendMessage(
sender,
Component.text("Usage: /score-archive-list [archiveId]").color(NamedTextColor.YELLOW));
}
}

View File

@@ -18,6 +18,9 @@ commands:
score-archive:
description: Archives and resets the player's score
usage: /score-archive [playername]
score-archive-list:
description: Lists saved score archives or displays one archive.
usage: /score-archive-list [archiveId]
score-bucket:
description: Selects or manages score buckets.
usage: /score-bucket [bucketId] | create <id> <singular> <plural> <initialValue> [admin|global|player|none] | <player|all> <bucketId>