Add high-score runs for score buckets
This commit is contained in:
@@ -12,6 +12,10 @@
|
|||||||
* `/score-bucket [bucketId]` - Switches your active bucket.
|
* `/score-bucket [bucketId]` - Switches your active bucket.
|
||||||
* `/score-bucket create <id> <singular> <plural> <initialValue> [admin|global|player|none]` - Creates a bucket (operators).
|
* `/score-bucket create <id> <singular> <plural> <initialValue> [admin|global|player|none]` - Creates a bucket (operators).
|
||||||
* `/score-bucket <playerName|all> <bucketId>` - Switches a player or the server default (operators).
|
* `/score-bucket <playerName|all> <bucketId>` - Switches a player or the server default (operators).
|
||||||
|
* `/score-run start <bucket> <topN> <reset:true|false> target <score>` - Starts a target-score run (operators).
|
||||||
|
* `/score-run start <bucket> <topN> <reset:true|false> duration <time>` - Starts a timed run; time uses `s`, `m`, `h`, `d`, or `w` (operators).
|
||||||
|
* `/score-run start <bucket> <topN> <reset:true|false> until-disabled` - Starts a run without automatic expiry (operators).
|
||||||
|
* `/score-run stop <bucket>` - Ends an active run (operators).
|
||||||
|
|
||||||
When a player's active bucket changes, they are told the bucket name and their current score with its singular or plural unit.
|
When a player's active bucket changes, they are told the bucket name and their current score with its singular or plural unit.
|
||||||
|
|
||||||
@@ -22,6 +26,8 @@ NOTES:
|
|||||||
|
|
||||||
Bucket reporting defaults to `none`; `admin` notifies operators and score admins, `global` notifies all online players, and `player` notifies only the scored player. Other plugins can create buckets with `createBucket(id, singular, plural, initialValue, reporting)` and use the bucket-aware score methods. Existing score methods operate on the player's active bucket. Bucket definitions, reporting policies, assignments, and scores are saved in `plugins/ScoreKeeper/scores.yml`.
|
Bucket reporting defaults to `none`; `admin` notifies operators and score admins, `global` notifies all online players, and `player` notifies only the scored player. Other plugins can create buckets with `createBucket(id, singular, plural, initialValue, reporting)` and use the bucket-aware score methods. Existing score methods operate on the player's active bucket. Bucket definitions, reporting policies, assignments, and scores are saved in `plugins/ScoreKeeper/scores.yml`.
|
||||||
|
|
||||||
|
Other plugins can start runs with `startTargetScoreRun`, `startTimedScoreRun`, or `startUntilDisabledScoreRun`, and end one with `stopHighScoreRun`. During a run, players receive their own score changes, while changes by current top-`n` players are reported globally. Finished runs publish a deterministic score-ordered table and each online player's place; reset runs restore the pre-run bucket scores.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for development environment setup instructions.
|
See [CONTRIBUTING.md](CONTRIBUTING.md) for development environment setup instructions.
|
||||||
|
|||||||
2
TODO.md
2
TODO.md
@@ -24,5 +24,5 @@
|
|||||||
| 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-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 | Done | 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-03 | Done | 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 | Done | Tell a player when their bucket changes. Something like "You are now tracking ${id}. You have ${score} ${plural or singular depending in score}." |
|
| SK-04 | Done | 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. |
|
| SK-05 | Done | 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. |
|
||||||
| SK-06 | Ready | Score archiving. Scores either all or within a given bucket should be able to be archived and reset to their initial value. The archived scores should be able to be listed via a command. |
|
| SK-06 | Ready | Score archiving. Scores either all or within a given bucket should be able to be archived and reset to their initial value. The archived scores should be able to be listed via a command. |
|
||||||
|
|||||||
@@ -19,7 +19,12 @@ package com.majinnaibu.minecraft.plugins.scorekeeper;
|
|||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.logging.Level;
|
import java.util.logging.Level;
|
||||||
@@ -29,6 +34,7 @@ import com.majinnaibu.minecraft.plugins.scorekeeper.commands.ScoreArchiveCommand
|
|||||||
import com.majinnaibu.minecraft.plugins.scorekeeper.commands.ScoreBucketCommand;
|
import com.majinnaibu.minecraft.plugins.scorekeeper.commands.ScoreBucketCommand;
|
||||||
import com.majinnaibu.minecraft.plugins.scorekeeper.commands.ScoreGetCommand;
|
import com.majinnaibu.minecraft.plugins.scorekeeper.commands.ScoreGetCommand;
|
||||||
import com.majinnaibu.minecraft.plugins.scorekeeper.commands.ScoreResetCommand;
|
import com.majinnaibu.minecraft.plugins.scorekeeper.commands.ScoreResetCommand;
|
||||||
|
import com.majinnaibu.minecraft.plugins.scorekeeper.commands.ScoreRunCommand;
|
||||||
import com.majinnaibu.minecraft.plugins.scorekeeper.commands.ScoreSubtractCommand;
|
import com.majinnaibu.minecraft.plugins.scorekeeper.commands.ScoreSubtractCommand;
|
||||||
|
|
||||||
import net.kyori.adventure.text.Component;
|
import net.kyori.adventure.text.Component;
|
||||||
@@ -44,6 +50,7 @@ public class ScoreKeeperPlugin extends JavaPlugin {
|
|||||||
private final Map<String, ScoreBucket> _buckets = new LinkedHashMap<>();
|
private final Map<String, ScoreBucket> _buckets = new LinkedHashMap<>();
|
||||||
private final Map<String, Map<UUID, Integer>> _bucketScores = new LinkedHashMap<>();
|
private final Map<String, Map<UUID, Integer>> _bucketScores = new LinkedHashMap<>();
|
||||||
private final Map<UUID, String> _playerBuckets = new LinkedHashMap<>();
|
private final Map<UUID, String> _playerBuckets = new LinkedHashMap<>();
|
||||||
|
private final Map<String, HighScoreRun> _highScoreRuns = new LinkedHashMap<>();
|
||||||
private String _defaultBucketId = "points";
|
private String _defaultBucketId = "points";
|
||||||
public final String _logPrefix = "[ScoreKeeper] ";
|
public final String _logPrefix = "[ScoreKeeper] ";
|
||||||
public final Component _messagePrefix =
|
public final Component _messagePrefix =
|
||||||
@@ -54,7 +61,7 @@ public class ScoreKeeperPlugin extends JavaPlugin {
|
|||||||
@Override
|
@Override
|
||||||
public void onDisable() {
|
public void onDisable() {
|
||||||
YamlConfiguration scores = new YamlConfiguration();
|
YamlConfiguration scores = new YamlConfiguration();
|
||||||
scores.set("version", 2);
|
scores.set("version", 3);
|
||||||
scores.set("default-bucket", _defaultBucketId);
|
scores.set("default-bucket", _defaultBucketId);
|
||||||
for (ScoreBucket bucket : _buckets.values()) {
|
for (ScoreBucket bucket : _buckets.values()) {
|
||||||
String path = "buckets." + bucket.getId();
|
String path = "buckets." + bucket.getId();
|
||||||
@@ -69,6 +76,17 @@ public class ScoreKeeperPlugin extends JavaPlugin {
|
|||||||
for (var entry : _playerBuckets.entrySet()) {
|
for (var entry : _playerBuckets.entrySet()) {
|
||||||
scores.set("player-buckets." + entry.getKey(), entry.getValue());
|
scores.set("player-buckets." + entry.getKey(), entry.getValue());
|
||||||
}
|
}
|
||||||
|
for (HighScoreRun run : _highScoreRuns.values()) {
|
||||||
|
String path = "high-score-runs." + run.bucketId;
|
||||||
|
scores.set(path + ".top-n", run.topN);
|
||||||
|
scores.set(path + ".reset-scores", run.resetScores);
|
||||||
|
scores.set(path + ".lifetime", run.lifetime.name().toLowerCase(Locale.ROOT));
|
||||||
|
scores.set(path + ".target-score", run.targetScore);
|
||||||
|
scores.set(path + ".expires-at", run.expiresAtMillis);
|
||||||
|
for (var entry : run.previousScores.entrySet()) {
|
||||||
|
scores.set(path + ".previous-scores." + entry.getKey(), entry.getValue());
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
scores.save(new File(getDataFolder(), "scores.yml"));
|
scores.save(new File(getDataFolder(), "scores.yml"));
|
||||||
} catch (IOException ex) {
|
} catch (IOException ex) {
|
||||||
@@ -84,10 +102,13 @@ public class ScoreKeeperPlugin extends JavaPlugin {
|
|||||||
getCommand("score-reset").setExecutor(new ScoreResetCommand(this));
|
getCommand("score-reset").setExecutor(new ScoreResetCommand(this));
|
||||||
getCommand("score-archive").setExecutor(new ScoreArchiveCommand(this));
|
getCommand("score-archive").setExecutor(new ScoreArchiveCommand(this));
|
||||||
getCommand("score-bucket").setExecutor(new ScoreBucketCommand(this));
|
getCommand("score-bucket").setExecutor(new ScoreBucketCommand(this));
|
||||||
|
getCommand("score-run").setExecutor(new ScoreRunCommand(this));
|
||||||
|
|
||||||
_buckets.put("points", new ScoreBucket("points", "point", "points", 0));
|
_buckets.put("points", new ScoreBucket("points", "point", "points", 0));
|
||||||
_bucketScores.put("points", new LinkedHashMap<>());
|
_bucketScores.put("points", new LinkedHashMap<>());
|
||||||
loadScores();
|
loadScores();
|
||||||
|
checkHighScoreRuns();
|
||||||
|
getServer().getScheduler().runTaskTimer(this, this::checkHighScoreRuns, 20L, 20L);
|
||||||
|
|
||||||
logInfo(
|
logInfo(
|
||||||
getPluginMeta().getName() + " version " + getPluginMeta().getVersion() + " is enabled.");
|
getPluginMeta().getName() + " version " + getPluginMeta().getVersion() + " is enabled.");
|
||||||
@@ -134,7 +155,15 @@ public class ScoreKeeperPlugin extends JavaPlugin {
|
|||||||
int oldScore = getScore(player, bucketId);
|
int oldScore = getScore(player, bucketId);
|
||||||
_bucketScores.get(bucketId).put(player.getUniqueId(), score);
|
_bucketScores.get(bucketId).put(player.getUniqueId(), score);
|
||||||
if (oldScore != score) {
|
if (oldScore != score) {
|
||||||
reportScoreChange(player, bucket, score);
|
HighScoreRun run = _highScoreRuns.get(bucketId);
|
||||||
|
if (run == null) {
|
||||||
|
reportScoreChange(player, bucket, score);
|
||||||
|
} else {
|
||||||
|
reportHighScoreRunChange(player, bucket, run, score);
|
||||||
|
if (run.lifetime == HighScoreLifetime.TARGET_SCORE && score >= run.targetScore) {
|
||||||
|
finishHighScoreRun(bucketId);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,6 +232,48 @@ public class ScoreKeeperPlugin extends JavaPlugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void startTargetScoreRun(String bucketId, int topN, boolean resetScores, int targetScore) {
|
||||||
|
ScoreBucket bucket = requireBucket(bucketId);
|
||||||
|
if (resetScores && targetScore <= bucket.getInitialValue()) {
|
||||||
|
throw new IllegalArgumentException("Target score must exceed the bucket's initial value");
|
||||||
|
}
|
||||||
|
startHighScoreRun(bucketId, topN, resetScores, HighScoreLifetime.TARGET_SCORE, targetScore, 0L);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void startTimedScoreRun(
|
||||||
|
String bucketId, int topN, boolean resetScores, Duration duration) {
|
||||||
|
if (duration == null || duration.isNegative() || duration.isZero()) {
|
||||||
|
throw new IllegalArgumentException("Run duration must be positive");
|
||||||
|
}
|
||||||
|
long durationMillis;
|
||||||
|
long expiresAtMillis;
|
||||||
|
try {
|
||||||
|
durationMillis = duration.toMillis();
|
||||||
|
expiresAtMillis = Math.addExact(System.currentTimeMillis(), durationMillis);
|
||||||
|
} catch (ArithmeticException ex) {
|
||||||
|
throw new IllegalArgumentException("Run duration is too large", ex);
|
||||||
|
}
|
||||||
|
if (durationMillis <= 0) {
|
||||||
|
throw new IllegalArgumentException("Run duration must be at least one millisecond");
|
||||||
|
}
|
||||||
|
startHighScoreRun(bucketId, topN, resetScores, HighScoreLifetime.DURATION, 0, expiresAtMillis);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void startUntilDisabledScoreRun(String bucketId, int topN, boolean resetScores) {
|
||||||
|
startHighScoreRun(bucketId, topN, resetScores, HighScoreLifetime.UNTIL_DISABLED, 0, 0L);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void stopHighScoreRun(String bucketId) {
|
||||||
|
if (!_highScoreRuns.containsKey(bucketId)) {
|
||||||
|
throw new IllegalArgumentException("No high-score run is active for bucket " + bucketId);
|
||||||
|
}
|
||||||
|
finishHighScoreRun(bucketId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean hasActiveHighScoreRun(String bucketId) {
|
||||||
|
return _highScoreRuns.containsKey(bucketId);
|
||||||
|
}
|
||||||
|
|
||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
// region Utiilty Methods
|
// region Utiilty Methods
|
||||||
@@ -227,6 +298,160 @@ public class ScoreKeeperPlugin extends JavaPlugin {
|
|||||||
"You are now tracking " + bucket.getId() + ". You have " + score + " " + unit + "."));
|
"You are now tracking " + bucket.getId() + ". You have " + score + " " + unit + "."));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void startHighScoreRun(
|
||||||
|
String bucketId,
|
||||||
|
int topN,
|
||||||
|
boolean resetScores,
|
||||||
|
HighScoreLifetime lifetime,
|
||||||
|
int targetScore,
|
||||||
|
long expiresAtMillis) {
|
||||||
|
ScoreBucket bucket = requireBucket(bucketId);
|
||||||
|
if (topN < 1) {
|
||||||
|
throw new IllegalArgumentException("Top N must be at least 1");
|
||||||
|
}
|
||||||
|
if (_highScoreRuns.containsKey(bucketId)) {
|
||||||
|
throw new IllegalArgumentException("A high-score run is already active for " + bucketId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<UUID, Integer> bucketScores = _bucketScores.get(bucketId);
|
||||||
|
Map<UUID, Integer> previousScores = resetScores ? new LinkedHashMap<>(bucketScores) : Map.of();
|
||||||
|
if (resetScores) {
|
||||||
|
bucketScores.replaceAll((playerId, score) -> bucket.getInitialValue());
|
||||||
|
}
|
||||||
|
_highScoreRuns.put(
|
||||||
|
bucketId,
|
||||||
|
new HighScoreRun(
|
||||||
|
bucketId, topN, resetScores, lifetime, targetScore, expiresAtMillis, previousScores));
|
||||||
|
checkHighScoreRuns();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void checkHighScoreRuns() {
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
List<String> finishedRuns = new ArrayList<>();
|
||||||
|
for (HighScoreRun run : _highScoreRuns.values()) {
|
||||||
|
boolean expired = run.lifetime == HighScoreLifetime.DURATION && now >= run.expiresAtMillis;
|
||||||
|
boolean targetReached =
|
||||||
|
run.lifetime == HighScoreLifetime.TARGET_SCORE
|
||||||
|
&& _bucketScores.get(run.bucketId).values().stream()
|
||||||
|
.anyMatch(score -> score >= run.targetScore);
|
||||||
|
if (expired || targetReached) {
|
||||||
|
finishedRuns.add(run.bucketId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (String bucketId : finishedRuns) {
|
||||||
|
finishHighScoreRun(bucketId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void reportHighScoreRunChange(
|
||||||
|
Player player, ScoreBucket bucket, HighScoreRun run, int score) {
|
||||||
|
String message = scoreMessage(player.getName(), bucket, score);
|
||||||
|
sendMessage(player, Component.text(message));
|
||||||
|
List<ScoreEntry> standings = getRankedScores(bucket.getId());
|
||||||
|
for (int place = 0; place < Math.min(run.topN, standings.size()); place++) {
|
||||||
|
if (standings.get(place).playerId.equals(player.getUniqueId())) {
|
||||||
|
for (Player recipient : getServer().getOnlinePlayers()) {
|
||||||
|
if (recipient != player) {
|
||||||
|
sendMessage(recipient, Component.text(message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void finishHighScoreRun(String bucketId) {
|
||||||
|
HighScoreRun run = _highScoreRuns.remove(bucketId);
|
||||||
|
if (run == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ScoreBucket bucket = requireBucket(bucketId);
|
||||||
|
List<ScoreEntry> standings = getRankedScores(bucketId);
|
||||||
|
StringBuilder table =
|
||||||
|
new StringBuilder("High-score run for ").append(bucketId).append(" finished.");
|
||||||
|
int places = Math.min(run.topN, standings.size());
|
||||||
|
if (places == 0) {
|
||||||
|
table.append("\nNo scores were recorded.");
|
||||||
|
}
|
||||||
|
for (int place = 0; place < places; place++) {
|
||||||
|
ScoreEntry entry = standings.get(place);
|
||||||
|
table
|
||||||
|
.append("\n")
|
||||||
|
.append(place + 1)
|
||||||
|
.append(". ")
|
||||||
|
.append(entry.playerName)
|
||||||
|
.append(" - ")
|
||||||
|
.append(entry.score)
|
||||||
|
.append(" ")
|
||||||
|
.append(scoreUnit(bucket, entry.score));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (run.resetScores) {
|
||||||
|
Map<UUID, Integer> bucketScores = _bucketScores.get(bucketId);
|
||||||
|
bucketScores.clear();
|
||||||
|
bucketScores.putAll(run.previousScores);
|
||||||
|
}
|
||||||
|
logInfo(table.toString().replace('\n', ' '));
|
||||||
|
for (Player player : getServer().getOnlinePlayers()) {
|
||||||
|
ScoreEntry personalEntry = null;
|
||||||
|
int personalPlace = -1;
|
||||||
|
for (int place = 0; place < standings.size(); place++) {
|
||||||
|
if (standings.get(place).playerId.equals(player.getUniqueId())) {
|
||||||
|
personalEntry = standings.get(place);
|
||||||
|
personalPlace = place + 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int personalScore = personalEntry == null ? bucket.getInitialValue() : personalEntry.score;
|
||||||
|
String personalResult =
|
||||||
|
personalPlace < 0
|
||||||
|
? "Your place: unranked; score: "
|
||||||
|
+ personalScore
|
||||||
|
+ " "
|
||||||
|
+ scoreUnit(bucket, personalScore)
|
||||||
|
: "Your place: "
|
||||||
|
+ personalPlace
|
||||||
|
+ "; score: "
|
||||||
|
+ personalScore
|
||||||
|
+ " "
|
||||||
|
+ scoreUnit(bucket, personalScore);
|
||||||
|
sendMessage(player, Component.text(table + "\n" + personalResult));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<ScoreEntry> getRankedScores(String bucketId) {
|
||||||
|
List<ScoreEntry> standings = new ArrayList<>();
|
||||||
|
for (var entry : _bucketScores.get(bucketId).entrySet()) {
|
||||||
|
String playerName = getServer().getOfflinePlayer(entry.getKey()).getName();
|
||||||
|
if (playerName == null || playerName.isBlank()) {
|
||||||
|
playerName = entry.getKey().toString();
|
||||||
|
}
|
||||||
|
standings.add(new ScoreEntry(entry.getKey(), playerName, entry.getValue()));
|
||||||
|
}
|
||||||
|
standings.sort(
|
||||||
|
Comparator.comparingInt((ScoreEntry entry) -> entry.score)
|
||||||
|
.reversed()
|
||||||
|
.thenComparing(entry -> entry.playerName, String.CASE_INSENSITIVE_ORDER)
|
||||||
|
.thenComparing(entry -> entry.playerId.toString()));
|
||||||
|
return standings;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String scoreMessage(String playerName, ScoreBucket bucket, int score) {
|
||||||
|
return playerName
|
||||||
|
+ "'s "
|
||||||
|
+ bucket.getId()
|
||||||
|
+ " score is now "
|
||||||
|
+ score
|
||||||
|
+ " "
|
||||||
|
+ scoreUnit(bucket, score)
|
||||||
|
+ ".";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String scoreUnit(ScoreBucket bucket, int score) {
|
||||||
|
return score == 1 ? bucket.getSingular() : bucket.getPlural();
|
||||||
|
}
|
||||||
|
|
||||||
private void loadScores() {
|
private void loadScores() {
|
||||||
File scoreFile = new File(getDataFolder(), "scores.yml");
|
File scoreFile = new File(getDataFolder(), "scores.yml");
|
||||||
if (!scoreFile.isFile()) {
|
if (!scoreFile.isFile()) {
|
||||||
@@ -286,6 +511,7 @@ public class ScoreKeeperPlugin extends JavaPlugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
loadHighScoreRuns(scores);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void loadLegacyScores(YamlConfiguration scores) {
|
private void loadLegacyScores(YamlConfiguration scores) {
|
||||||
@@ -311,15 +537,55 @@ public class ScoreKeeperPlugin extends JavaPlugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void loadHighScoreRuns(YamlConfiguration scores) {
|
||||||
|
ConfigurationSection runs = scores.getConfigurationSection("high-score-runs");
|
||||||
|
if (runs == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (String bucketId : runs.getKeys(false)) {
|
||||||
|
if (!_buckets.containsKey(bucketId)) {
|
||||||
|
logWarning("Ignoring high-score run for unknown bucket " + bucketId + ".");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
String path = "high-score-runs." + bucketId;
|
||||||
|
int topN = scores.getInt(path + ".top-n");
|
||||||
|
if (topN < 1) {
|
||||||
|
throw new IllegalArgumentException("Top N must be at least 1");
|
||||||
|
}
|
||||||
|
HighScoreLifetime lifetime =
|
||||||
|
HighScoreLifetime.valueOf(
|
||||||
|
scores.getString(path + ".lifetime", "until_disabled").toUpperCase(Locale.ROOT));
|
||||||
|
Map<UUID, Integer> previousScores = new LinkedHashMap<>();
|
||||||
|
ConfigurationSection savedScores =
|
||||||
|
scores.getConfigurationSection(path + ".previous-scores");
|
||||||
|
if (savedScores != null) {
|
||||||
|
loadPlayerScores(savedScores, previousScores);
|
||||||
|
}
|
||||||
|
_highScoreRuns.put(
|
||||||
|
bucketId,
|
||||||
|
new HighScoreRun(
|
||||||
|
bucketId,
|
||||||
|
topN,
|
||||||
|
scores.getBoolean(path + ".reset-scores"),
|
||||||
|
lifetime,
|
||||||
|
scores.getInt(path + ".target-score"),
|
||||||
|
scores.getLong(path + ".expires-at"),
|
||||||
|
previousScores));
|
||||||
|
} catch (IllegalArgumentException ex) {
|
||||||
|
logWarning(
|
||||||
|
"Ignoring invalid high-score run for bucket " + bucketId + ": " + ex.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void reportScoreChange(Player player, ScoreBucket bucket, int score) {
|
private void reportScoreChange(Player player, ScoreBucket bucket, int score) {
|
||||||
ScoreReporting reporting = bucket.getReporting();
|
ScoreReporting reporting = bucket.getReporting();
|
||||||
if (reporting == ScoreReporting.NONE) {
|
if (reporting == ScoreReporting.NONE) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
String unit = score == 1 ? bucket.getSingular() : bucket.getPlural();
|
String text = scoreMessage(player.getName(), bucket, score);
|
||||||
String text =
|
|
||||||
player.getName() + "'s " + bucket.getId() + " score is now " + score + " " + unit + ".";
|
|
||||||
Component message = Component.text(text);
|
Component message = Component.text(text);
|
||||||
if (reporting == ScoreReporting.PLAYER) {
|
if (reporting == ScoreReporting.PLAYER) {
|
||||||
sendMessage(player, message);
|
sendMessage(player, message);
|
||||||
@@ -337,6 +603,41 @@ public class ScoreKeeperPlugin extends JavaPlugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private enum HighScoreLifetime {
|
||||||
|
TARGET_SCORE,
|
||||||
|
DURATION,
|
||||||
|
UNTIL_DISABLED
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class HighScoreRun {
|
||||||
|
private final String bucketId;
|
||||||
|
private final int topN;
|
||||||
|
private final boolean resetScores;
|
||||||
|
private final HighScoreLifetime lifetime;
|
||||||
|
private final int targetScore;
|
||||||
|
private final long expiresAtMillis;
|
||||||
|
private final Map<UUID, Integer> previousScores;
|
||||||
|
|
||||||
|
private HighScoreRun(
|
||||||
|
String bucketId,
|
||||||
|
int topN,
|
||||||
|
boolean resetScores,
|
||||||
|
HighScoreLifetime lifetime,
|
||||||
|
int targetScore,
|
||||||
|
long expiresAtMillis,
|
||||||
|
Map<UUID, Integer> previousScores) {
|
||||||
|
this.bucketId = bucketId;
|
||||||
|
this.topN = topN;
|
||||||
|
this.resetScores = resetScores;
|
||||||
|
this.lifetime = lifetime;
|
||||||
|
this.targetScore = targetScore;
|
||||||
|
this.expiresAtMillis = expiresAtMillis;
|
||||||
|
this.previousScores = new LinkedHashMap<>(previousScores);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private record ScoreEntry(UUID playerId, String playerName, int score) {}
|
||||||
|
|
||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
// region Logging
|
// region Logging
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
/*
|
||||||
|
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.Duration;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
public class ScoreRunCommand implements CommandExecutor {
|
||||||
|
private final ScoreKeeperPlugin _plugin;
|
||||||
|
|
||||||
|
public ScoreRunCommand(ScoreKeeperPlugin plugin) {
|
||||||
|
_plugin = plugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
|
||||||
|
if (!sender.hasPermission("scorekeeper.admin")) {
|
||||||
|
sendError(sender, "You do not have permission to manage high-score runs");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.length == 2 && args[0].equalsIgnoreCase("stop")) {
|
||||||
|
try {
|
||||||
|
_plugin.stopHighScoreRun(args[1]);
|
||||||
|
} catch (IllegalArgumentException ex) {
|
||||||
|
sendError(sender, ex.getMessage());
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.length < 5 || !args[0].equalsIgnoreCase("start")) {
|
||||||
|
sendUsage(sender);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
String bucketId = args[1];
|
||||||
|
try {
|
||||||
|
int topN = Integer.parseInt(args[2]);
|
||||||
|
boolean resetScores = parseBoolean(args[3]);
|
||||||
|
String lifetime = args[4].toLowerCase();
|
||||||
|
if (lifetime.equals("target") && args.length == 6) {
|
||||||
|
_plugin.startTargetScoreRun(bucketId, topN, resetScores, Integer.parseInt(args[5]));
|
||||||
|
} else if (lifetime.equals("duration") && args.length == 6) {
|
||||||
|
_plugin.startTimedScoreRun(bucketId, topN, resetScores, parseDuration(args[5]));
|
||||||
|
} else if (lifetime.equals("until-disabled") && args.length == 5) {
|
||||||
|
_plugin.startUntilDisabledScoreRun(bucketId, topN, resetScores);
|
||||||
|
} else {
|
||||||
|
sendUsage(sender);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
_plugin.sendMessage(sender, Component.text("Started a high-score run for " + bucketId + "."));
|
||||||
|
} catch (IllegalArgumentException ex) {
|
||||||
|
sendError(sender, ex.getMessage());
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean parseBoolean(String value) {
|
||||||
|
if (value.equalsIgnoreCase("true")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (value.equalsIgnoreCase("false")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
throw new IllegalArgumentException("Reset scores must be true or false");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Duration parseDuration(String value) {
|
||||||
|
if (!value.matches("[0-9]+[smhdw]")) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Duration must use seconds, minutes, hours, days or weeks (e.g. 4h)");
|
||||||
|
}
|
||||||
|
long amount = Long.parseLong(value.substring(0, value.length() - 1));
|
||||||
|
try {
|
||||||
|
return switch (value.charAt(value.length() - 1)) {
|
||||||
|
case 's' -> Duration.ofSeconds(amount);
|
||||||
|
case 'm' -> Duration.ofMinutes(amount);
|
||||||
|
case 'h' -> Duration.ofHours(amount);
|
||||||
|
case 'd' -> Duration.ofDays(amount);
|
||||||
|
case 'w' -> Duration.ofDays(Math.multiplyExact(amount, 7));
|
||||||
|
default -> throw new IllegalArgumentException("Unsupported duration unit");
|
||||||
|
};
|
||||||
|
} catch (ArithmeticException ex) {
|
||||||
|
throw new IllegalArgumentException("Duration is too large", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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-run start <bucket> <topN> <reset:true|false> "
|
||||||
|
+ "<target <score>|duration <time>|until-disabled> | /score-run stop <bucket>")
|
||||||
|
.color(NamedTextColor.YELLOW));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,9 @@ commands:
|
|||||||
score-bucket:
|
score-bucket:
|
||||||
description: Selects or manages score buckets.
|
description: Selects or manages score buckets.
|
||||||
usage: /score-bucket [bucketId] | create <id> <singular> <plural> <initialValue> [admin|global|player|none] | <player|all> <bucketId>
|
usage: /score-bucket [bucketId] | create <id> <singular> <plural> <initialValue> [admin|global|player|none] | <player|all> <bucketId>
|
||||||
|
score-run:
|
||||||
|
description: Starts or stops a high-score run.
|
||||||
|
usage: /score-run start <bucket> <topN> <reset:true|false> <target <score>|duration <time>|until-disabled> | /score-run stop <bucket>
|
||||||
permissions:
|
permissions:
|
||||||
scorekeeper.admin:
|
scorekeeper.admin:
|
||||||
description: Allows managing score buckets.
|
description: Allows managing score buckets.
|
||||||
|
|||||||
Reference in New Issue
Block a user