diff --git a/src/main/java/tfmc/justin/activity/ActivityAPI.java b/src/main/java/tfmc/justin/activity/ActivityAPI.java
index 821d3a3..f260402 100644
--- a/src/main/java/tfmc/justin/activity/ActivityAPI.java
+++ b/src/main/java/tfmc/justin/activity/ActivityAPI.java
@@ -5,28 +5,12 @@
import java.util.UUID;
-// ====================================
-// The intake path for plugins that compile against this jar. The other two
-// are the custom events and /activity add, so nothing has to depend on us.
-// ====================================
public final class ActivityAPI {
private ActivityAPI() {
}
- /**
- * Record progress towards an activity for a player.
- *
- * Main thread only. Unknown activity ids and non-positive amounts are ignored.
- *
- * @param uuid the player, online or not
- * @param activityId an id from the 'activities' section of config.yml
- * @param amount how much progress to add
- * @throws IllegalStateException if called off the main thread
- */
public static void record(UUID uuid, String activityId, int amount) {
- // Loud rather than a corrupted store: everything downstream mutates
- // player state without a lock because it assumes it is on a tick
if (!Bukkit.isPrimaryThread()) {
throw new IllegalStateException("ActivityAPI.record must be called on the main thread");
}
diff --git a/src/main/java/tfmc/justin/activity/ActivityPlugin.java b/src/main/java/tfmc/justin/activity/ActivityPlugin.java
index ed8b9a0..1b34b07 100644
--- a/src/main/java/tfmc/justin/activity/ActivityPlugin.java
+++ b/src/main/java/tfmc/justin/activity/ActivityPlugin.java
@@ -27,8 +27,6 @@
public class ActivityPlugin extends JavaPlugin {
- // Held only so onDisable can take it back out of PlaceholderAPI - an
- // expansion left registered keeps this classloader alive across a reload
private PlaceholderHook placeholderHook;
@Override
@@ -45,7 +43,6 @@ public void onEnable() {
getCommand("activity").setTabCompleter(command);
getServer().getPluginManager().registerEvents(new JoinListener(manager), this);
- // Vanilla crafting - no soft-depend to check, unlike registerHooks below
getServer().getPluginManager().registerEvents(new CraftListener(manager), this);
getServer().getPluginManager().registerEvents(gui, this);
@@ -56,11 +53,6 @@ public void onEnable() {
@Override
public void onDisable() {
- // ====================================
- // Save first: the final write must land no matter what happens next.
- // Unregistering from PAPI runs after, wrapped so a teardown throw from
- // another plugin's classloader can never follow-skip or interrupt it.
- // ====================================
if (ActivityManager.getInstance() != null) {
ActivityManager.getInstance().shutdown();
}
@@ -76,12 +68,6 @@ public void onDisable() {
getLogger().info("activity has been disabled!");
}
- // ====================================
- // Source plugins are compiled against but optional at runtime. Each
- // listener is only constructed inside its own isPluginEnabled check, so
- // the class - and the missing event type it references - is never loaded
- // on a server that does not have the plugin installed.
- // ====================================
private void registerHooks() {
if (Bukkit.getPluginManager().isPluginEnabled("VotingPlugin")) {
getServer().getPluginManager().registerEvents(new VoteListener(this, ActivityManager.getInstance()), this);
diff --git a/src/main/java/tfmc/justin/activity/commands/ActivityCommand.java b/src/main/java/tfmc/justin/activity/commands/ActivityCommand.java
index 68deb02..994bd6d 100644
--- a/src/main/java/tfmc/justin/activity/commands/ActivityCommand.java
+++ b/src/main/java/tfmc/justin/activity/commands/ActivityCommand.java
@@ -25,43 +25,17 @@
import java.util.Locale;
import java.util.UUID;
-// ====================================
-// /activity opens the GUI; the subcommands are admin-only.
-// 'add' doubles as the intake path for ConditionalEvents and anything else
-// that can dispatch a console command, so it goes through the same daily-task
-// gate a listener does: nothing is credited unless the activity is one of the
-// player's revealed tasks today. A trailing --force skips the gate and both
-// daily limits - the activity's daily-cap and bar.daily-max - so staff testing
-// an activity get exactly the points they asked for. bar.max still holds, and
-// an award it cuts short is reported as such.
-// ====================================
public class ActivityCommand implements CommandExecutor, TabCompleter {
private static final List SUBCOMMANDS =
Arrays.asList("reload", "check", "reset", "givereroll", "add", "addpoints");
- // What 'check' alone gets: the read-only half of the command
private static final List READ_ONLY_SUBCOMMANDS = List.of("check");
- // The one optional trailing argument 'add' takes
private static final String FORCE = "--force";
- // Well above any real day's activity. A forced add is no longer bounded
- // by daily-cap or bar.daily-max, so this alone does not keep the award
- // small: count times an activity's 'points' can still saturate at
- // Integer.MAX_VALUE. What bounds the award is PlayerData.addPoints, which
- // sums in long and clamps to bar.max, and ActivityDef.rawWorth, which
- // saturates rather than wrapping.
private static final int MAX_ADD = 1_000_000;
- // ====================================
- // ADDED has two lines. The figure lives on its own key rather than in
- // admin.add-done, because that key already exists in every deployed
- // messages.yml without a %points% placeholder and the live file wins over
- // the packaged default - the figure would be invisible in production.
- // The plain key is still what an add part-way to its next award gets:
- // "0 points." reads like a failure on the most common staff action.
- // ====================================
private static final String ADD_DONE_POINTS = "admin.add-done-points";
private final ActivityManager manager;
@@ -72,7 +46,6 @@ public ActivityCommand(ActivityManager manager, ActivityGui gui) {
this.gui = gui;
}
- // Fetched per use rather than cached - /activity reload swaps the file
private Messages messages() {
return manager.getConfiguration().messages();
}
@@ -85,12 +58,6 @@ public boolean onCommand(CommandSender sender, Command command, String label, St
}
String sub = args[0].toLowerCase(Locale.ROOT);
- // ====================================
- // An unrecognised subcommand is a usage error, not a permission one:
- // permissionFor() maps it to activity.admin, which used to hide the
- // usage line from exactly the check-only sender who mistyped. A sender
- // holding neither permission still learns nothing.
- // ====================================
if (!SUBCOMMANDS.contains(sub)) {
boolean anything = sender.hasPermission("activity.admin") || sender.hasPermission("activity.check");
if (anything) {
@@ -103,9 +70,6 @@ public boolean onCommand(CommandSender sender, Command command, String label, St
if (!sender.hasPermission(permissionFor(sub))) {
sender.sendMessage(messages().get("admin.no-permission"));
- // A refused mutating attempt is logged too: someone probing for
- // what they are allowed to run must not do it silently. 'check' is
- // read-only and stays out of the log whichever way it ends.
if (!sub.equals("check")) {
audit(sender, "action=" + sub + " result=denied");
}
@@ -134,22 +98,11 @@ public boolean onCommand(CommandSender sender, Command command, String label, St
handleAddPoints(sender, args);
return true;
default:
- // Unreachable: sub is one of SUBCOMMANDS, checked above, and
- // every case here matches one of them. Kept because the
- // compiler cannot prove that and requires this switch
- // statement to return on every path.
usage(sender);
return true;
}
}
- // ====================================
- // addpoints has a usage line of its own rather than a place in
- // admin.usage: every deployed messages.yml already has admin.usage, the
- // live file wins over the packaged one, and a new key is the only way the
- // line reaches a server that is already running - the same reason
- // ADD_DONE_POINTS is its own key.
- // ====================================
private void usage(CommandSender sender) {
sender.sendMessage(messages().get("admin.usage"));
sender.sendMessage(messages().get("admin.usage-addpoints"));
@@ -165,8 +118,6 @@ private void openGui(CommandSender sender) {
return;
}
Inventory inventory = gui.build(player);
- // After build(), which makes today's draw good: retries a daily reward
- // that failed, or pays one a group joined since the last reveal earned
manager.claimDailyReward(player);
player.openInventory(inventory);
}
@@ -189,23 +140,10 @@ private void handleReset(CommandSender sender, String[] args) {
data.reset(keys.week(), keys.day());
manager.getStore().markDirty();
- // The resolved name, not what was typed - casing and the cache decide
- // who was actually reset
sender.sendMessage(messages().get("admin.reset-done", "%player%", name(target, args[1])));
audit(sender, "action=reset " + who(target, args[1]) + " result=done");
}
- // ====================================
- // /activity givereroll gives one consumed reroll back - it does
- // NOT reroll anything, which is why it is not called 'reroll'.
- //
- // peek() rather than rolled(): a player with no row, and a player whose
- // stored day is not today, have both used no rerolls today - and asking
- // about them must neither put them in the file nor perform the rollover
- // as a side effect, which used to wipe a stale player's day while the
- // audit line said 'noop'. It is also what 'check' reports for them, so
- // the two commands cannot contradict each other.
- // ====================================
private void handleGiveReroll(CommandSender sender, String[] args) {
if (args.length < 2) {
usage(sender);
@@ -236,27 +174,10 @@ private void handleGiveReroll(CommandSender sender, String[] args) {
+ " result=" + (given ? "done" : "noop"));
}
- // Whether this row's stored day is the one a rollover would leave it on.
- // A stale week implies a stale day: roll() wipes the day either way.
private static boolean isToday(PlayerData data, ActivityConfiguration.Keys keys) {
return data != null && data.weekKey().equals(keys.week()) && data.dayKey().equals(keys.day());
}
- // ====================================
- // /activity check : everything staff needs to answer "why did I
- // not get a point", and nothing else. peek() is the read-only lookup - it
- // neither creates a row for a player who has none nor rolls the one it
- // finds.
- //
- // The rollover is applied to the answer instead, the way PlaceholderHook
- // does it: a stored week that is not the current one has nothing on the
- // bar, a stale day has nothing done today and no draw. Printing the raw
- // stored numbers under a "Today" label described a day the player's next
- // login will wipe - and contradicted /activity givereroll, which reports
- // the same stale player as having used no rerolls. The stored keys are
- // still printed, and said to be stale, because a stale pair is itself the
- // answer to some reports.
- // ====================================
private void handleCheck(CommandSender sender, String[] args) {
if (args.length < 2) {
usage(sender);
@@ -276,15 +197,9 @@ private void handleCheck(CommandSender sender, String[] args) {
}
ActivityConfiguration config = manager.getConfiguration();
- // One clock reading for both keys, so they cannot straddle a midnight
- // tick and disagree about which day this week it is
ActivityConfiguration.Keys keys = config.currentKeys();
boolean currentWeek = data.weekKey().equals(keys.week());
boolean today = isToday(data, keys);
- // Clamped for display only, the way PlaceholderHook clamps: nothing in
- // the model ever clamps claimedPoints, and PlayerData.clamp touches
- // only the weekly points, so after a lowered bar.max this would
- // otherwise print "60/50" for good - no write puts it right.
int points = currentWeek ? Math.min(data.points(), config.barMax()) : 0;
int claimed = currentWeek ? Math.min(data.claimedPoints(), config.barMax()) : 0;
@@ -295,16 +210,11 @@ private void handleCheck(CommandSender sender, String[] args) {
}
sender.sendMessage(messages().get("admin.check-points", "%points%", points,
"%max%", config.barMax(), "%claimed%", claimed));
- // Clamped like the points above, and for the same reason: nothing
- // clamps dailyPoints anywhere in the model - PlayerData.clamp is only
- // about the weekly points - so after a lowered bar.daily-max this
- // display clamp is the only thing between the operator and "15/10".
int dailyPoints = today ? Math.min(data.dailyPoints(), config.dailyMax()) : 0;
sender.sendMessage(messages().get("admin.check-daily", "%points%", dailyPoints,
"%max%", config.dailyMax()));
sender.sendMessage(messages().get("admin.check-rerolls", "%used%", today ? data.rerolls() : 0,
"%max%", config.rerollsPerDay()));
- // A stale day's flag is one the next login clears
sender.sendMessage(messages().get(today && data.dailyRewardClaimed()
? "admin.check-daily-reward-claimed" : "admin.check-daily-reward-unclaimed"));
sender.sendMessage(messages().get("admin.check-keys", "%week%", data.weekKey(),
@@ -319,18 +229,9 @@ private void handleCheck(CommandSender sender, String[] args) {
for (int slot = 0; slot < tasks.size(); slot++) {
String id = tasks.get(slot);
ActivityDef def = config.activity(id);
- // An id a reload has dropped still sits in the draw until the
- // player is next touched, so it is named by its raw id.
- // Unlike the GUI, this is plain console/chat text with no item
- // lore to carry colour, so the name is stripped of every code
- // the config accepts (legacy &, hex #rrggbb, rrggbb, ...) via
- // the same translator the GUI uses before printing it.
String display = def == null ? id
: ChatColor.stripColor(Utils.colorize(def.display()));
int count = data.count(id);
- // The display name goes in last: it is config text, and
- // substitution walks the pairs in order, so nothing in it can
- // stand in for a placeholder that has not been filled yet
sender.sendMessage(data.isRevealed(id)
? messages().get("admin.check-task-revealed", "%slot%", slot + 1, "%count%", count,
"%points%", def == null ? 0 : def.rawWorth(count), "%activity%", display)
@@ -338,13 +239,6 @@ private void handleCheck(CommandSender sender, String[] args) {
}
}
- // ====================================
- // One line per admin action that changed something, at INFO, after the
- // fact and carrying what came of it - a line in the log means it really
- // happened. 'check' is read-only and writes none; 'reload' does, because
- // "who swapped the config out from under us" is the same question.
- // Names are sanitised: they reach here from chat.
- // ====================================
private void audit(CommandSender sender, String what) {
manager.logger().info("ACTIVITY-AUDIT sender=" + quoted(sender.getName()) + " " + what);
}
@@ -353,20 +247,10 @@ private String who(OfflinePlayer target, String typed) {
return "target=" + quoted(name(target, typed)) + " uuid=" + target.getUniqueId();
}
- // ====================================
- // A value that came from a player goes into the line quoted - see
- // Utils.quotedForLog, which the click-command audit line shares. uuid=
- // needs none of it: it cannot be anything but a uuid.
- // ====================================
private static String quoted(String value) {
return Utils.quotedForLog(value);
}
- // ====================================
- // The one read-only subcommand has its own permission; activity.admin
- // implies it through plugin.yml, so nothing an admin could do before
- // needs a config change. Package-private for the test.
- // ====================================
static String permissionFor(String sub) {
return sub.equals("check") ? "activity.check" : "activity.admin";
}
@@ -393,8 +277,6 @@ private void handleAdd(CommandSender sender, String[] args) {
return;
}
- // A parseable number that is simply too big is a different mistake
- // from a typo, and saying "not a number" about 5000000 helps nobody
if (count <= 0 || count > MAX_ADD) {
sender.sendMessage(messages().get("admin.out-of-range", "%max%", MAX_ADD));
audit(sender, "action=add " + who(target, args[1]) + " result=out-of-range");
@@ -418,13 +300,6 @@ private void handleAdd(CommandSender sender, String[] args) {
+ " result=" + outcome);
}
- // ====================================
- // /activity addpoints : exactly that many points, with
- // no activity behind them. Capped like add --force - bar.max only - and
- // any part bar.max cut off is said, not swallowed. No upper bound of its
- // own: the credit sums in long and clamps, so a huge figure just fills
- // the bar, and one past int range fails to parse like any typo.
- // ====================================
private void handleAddPoints(CommandSender sender, String[] args) {
if (args.length != 3) {
usage(sender);
@@ -445,8 +320,6 @@ private void handleAddPoints(CommandSender sender, String[] args) {
points = 0;
}
if (points <= 0) {
- // Echoed back stripped, the way check strips a display name:
- // what was typed must not recolour or restyle the reply
sender.sendMessage(messages().get("admin.addpoints-invalid",
"%value%", ChatColor.stripColor(Utils.colorize(args[2]))));
audit(sender, "action=addpoints " + who(target, args[1]) + " value=" + quoted(args[2])
@@ -463,14 +336,6 @@ private void handleAddPoints(CommandSender sender, String[] args) {
+ " points=" + result.pointsAwarded() + " result=" + result.outcome());
}
- // ====================================
- // What a /activity add did, and the message that says so. Every way an
- // add can come to nothing gets its own line: a task the player has not
- // revealed today (or an offline player with no row at all) records
- // nothing, and a spent daily budget, a full weekly bar or an activity
- // that has already given all it can today record the count but credit no
- // points - all of which used to be reported as a plain success.
- // ====================================
enum Outcome {
ADDED("admin.add-done"),
NOT_A_TASK("admin.add-not-a-task"),
@@ -478,7 +343,6 @@ enum Outcome {
CAPPED_DAILY("admin.add-capped-daily"),
CAPPED_VOTE_SHARE("admin.add-capped-vote-share"),
CAPPED_WEEKLY("admin.add-capped-weekly"),
- // Forced only: some points landed and bar.max swallowed the rest
CLAMPED_WEEKLY("admin.add-clamped-weekly"),
UNKNOWN_ACTIVITY("admin.unknown-activity");
@@ -493,15 +357,6 @@ String messageKey() {
}
}
- // ====================================
- // The add decision itself, split out so it can be tested without a
- // server. Gated like any listener unless --force was asked for, so a
- // console intake path cannot quietly hand out points for a task the
- // player never drew or revealed. The record path itself is what decides
- // between an unknown id, a refused gate and a cap - this only names the
- // message for what it reports, and carries the points it awarded along so
- // the reply can say how many actually landed.
- // ====================================
Added add(UUID uuid, String activityId, int count, boolean force) {
RecordResult result = manager.recordAdmin(uuid, activityId, count, force);
@@ -518,37 +373,17 @@ Added add(UUID uuid, String activityId, int count, boolean force) {
return new Added(outcome, result.pointsAwarded());
}
- // What the add came to and how many points actually reached the bar
record Added(Outcome outcome, int points) {
}
- // ====================================
- // add takes exactly player, activity, count and at most the one trailing
- // --force. Anything else in that position - '--froce', 'force', a stray
- // word after it - used to parse as a plain gated add, so a typo silently
- // changed what the command did. Package-private for the test.
- // ====================================
static boolean wellFormedAdd(String[] args) {
return args.length == 4 || (args.length == 5 && forced(args));
}
- // ====================================
- // Whether 'add' was asked to skip the daily-task gate. Exact match: the
- // one argument that changes what the command does is not worth leaving
- // ambiguous, so '--FORCE' is a typo like any other and falls out of
- // wellFormedAdd as a usage error rather than quietly skipping the gate.
- // Package-private so it can be tested.
- // ====================================
static boolean forced(String[] args) {
return args.length > 4 && args[4].equals(FORCE);
}
- // ====================================
- // Online first, then the user cache. Bukkit.getOfflinePlayer(String) is
- // never used: it blocks the server on a Mojang lookup and, failing that,
- // invents a UUID - so a typo would create an entry for a player that does
- // not exist and report success.
- // ====================================
OfflinePlayer resolve(CommandSender sender, String name) {
Player online = Bukkit.getPlayerExact(name);
if (online != null) {
@@ -579,8 +414,6 @@ public List onTabComplete(CommandSender sender, Command command, String
}
String sub = args[0].toLowerCase(Locale.ROOT);
- // A check-only sender is completed for 'check' and nothing else, so
- // the completer never hints at a subcommand it would be refused
if (!sender.hasPermission(permissionFor(sub))) {
return Collections.emptyList();
}
@@ -606,7 +439,6 @@ public List onTabComplete(CommandSender sender, Command command, String
return Collections.emptyList();
}
- // Package-private so the completer can be driven without a server
List onlineNames() {
List names = new ArrayList<>();
for (Player player : Bukkit.getOnlinePlayers()) {
diff --git a/src/main/java/tfmc/justin/activity/config/ActivityConfiguration.java b/src/main/java/tfmc/justin/activity/config/ActivityConfiguration.java
index b2237a6..ba29281 100644
--- a/src/main/java/tfmc/justin/activity/config/ActivityConfiguration.java
+++ b/src/main/java/tfmc/justin/activity/config/ActivityConfiguration.java
@@ -29,135 +29,47 @@
import java.util.TreeSet;
import java.util.function.Predicate;
-// ====================================
-// Typed view over config.yml. Everything is read once on load so the hot
-// paths (every recorded action, every GUI open) never touch YAML.
-// ====================================
public class ActivityConfiguration {
private final JavaPlugin plugin;
private final Messages messages;
- // ====================================
- // Insertion-ordered, the order config.yml lists them in.
- // Volatile and replaced wholesale on reload rather than cleared and
- // refilled, because PlaceholderAPI reads it from other threads and must
- // never see a half-rebuilt map.
- // ====================================
private volatile Map activities = new LinkedHashMap<>();
- // ====================================
- // Crafted material -> the activity id its 'craft:' key belongs to. Built
- // once here so CraftListener answers an event with one map lookup instead
- // of walking every activity. Replaced wholesale on reload, same as above.
- // ====================================
private volatile Map craftActivities = new HashMap<>();
- // ====================================
- // The ids marked 'daily-guaranteed: true', in config order - the ones the
- // daily draw always hands out. Immutable and replaced wholesale on reload
- // for the same reason as the maps around it.
- // ====================================
private volatile List guaranteedActivities = List.of();
- // ====================================
- // The same thing for the 'craft:' keys written as a TLibs m..
- // path: those cannot be keyed by Material, so they are walked in config
- // order and the first path the crafted item matches wins. Only reached
- // when the Material lookup above missed, so a server with no such
- // activity pays nothing for them.
- // ====================================
private volatile List> craftPaths = List.of();
- // Read once on load rather than per craft event; TLibs is a softdepend,
- // so it is already enabled or already absent by the time we load.
- //
- // TLibs, further gated on MMOItems and MythicLib being enabled too: an
- // m.. path is resolved by TLibs but built from those two, so
- // TLibs alone is not enough to trust a path against - it would just
- // report every one of them as broken on first use instead of never
- // trying. Read by both the craft/icon paths above and the GUI, so both
- // ask the same question the same way.
private volatile boolean itemPathsUsable;
- // Which of TLibs/MMOItems/MythicLib are not enabled, ready to be named in
- // the one warning that is worth logging - and only if config.yml actually
- // asks for an m. path. A server that uses none must stay silent.
private String missingItemPathPlugins = "";
- // Set while parsing when any well-formed m.. value is seen, on
- // an icon or on a craft
private boolean pluginPathConfigured;
- // Whether an ia. path can be resolved right now. Deliberately
- // its own flag rather than another entry in the TLibs/MMOItems/MythicLib
- // list above: those three are what an m. path is built from, ItemsAdder has
- // nothing to do with them, and folding it in would stop every m. path from
- // resolving on a server that simply does not run ItemsAdder.
private volatile boolean itemsAdderUsable;
- // Set while parsing when any well-formed ia. value is seen,
- // on an icon or on a reward item
private boolean itemsAdderPathConfigured;
- // ====================================
- // MMOCore profession id (normalized, see normalizeProfessionId) ->
- // activity id. Built once on load so the experience-gain listener, which
- // fires on every ore broken and every crop harvested, is a single map
- // lookup instead of a scan.
- // Replaced wholesale alongside 'activities' for the same reason.
- // ====================================
private volatile Map professionActivities = Map.of();
- // ====================================
- // MMOItems crafting stations. Key is either '' (any recipe at
- // that station) or '/', both normalized the same way as
- // the 'station:' values they came from, so MmoItemsStationListener only
- // has to hand over the two ids it got from the event.
- // ====================================
private volatile Map stationActivities = Map.of();
- // ====================================
- // volatile: read from PlaceholderAPI's own threads after /activity reload
- // rebuilds them on the main thread, so readers need a visibility guarantee.
- // ====================================
private volatile DayOfWeek resetDay;
private volatile int resetHour;
- // ====================================
- // Every pool under rewards:, keyed by its lower-cased name: 'pool' (the
- // default one) and any 'pool_'. Replaced wholesale on reload and
- // read on the main thread only, but kept immutable so a reward command
- // that reloads mid-claim cannot change the list the claim is paying out of.
- // ====================================
private volatile Map> rewardPools = Map.of();
- // What every 'items:' amount is multiplied by at payout. Read fresh on
- // every handover rather than folded into the pool at load, so a reload
- // changes what the next claim pays.
private volatile int rewardMultiplier = 1;
- // rewards.drops: the fixed reward of a milestone, keyed by its point
- // total. A milestone with no entry here draws from the pool.
private volatile Map milestoneDrops = Map.of();
- // daily-reward.groups: group name -> the item it pays, in config order,
- // or a pool reference (see poolRef) for a group that draws once from a pool
private volatile Map dailyRewards = Map.of();
- // ====================================
- // A reference to a reward pool, used as a rewards.drops or a
- // daily-reward.groups value so both stay one map of RewardEntry: weight 0
- // with neither a command nor an item, which no parsed reward can be (both
- // are refused at load), and the pool's name in 'display'. DAILY_POOL is
- // the default pool's reference, kept as a constant because it is the one
- // every 'pool' value has meant since before named pools existed.
- // ====================================
public static final String DEFAULT_POOL = "pool";
public static final RewardEntry DAILY_POOL = new RewardEntry(0, DEFAULT_POOL, List.of(), List.of());
- // A rewards: key, or a drop/group value, that names a pool - matched
- // trimmed and case-insensitively, so 'Pool_Skins' and 'pool_skins' are one
public static boolean isPoolName(String value) {
String name = poolName(value);
return name.equals(DEFAULT_POOL) || name.startsWith(DEFAULT_POOL + "_");
@@ -172,7 +84,6 @@ public static RewardEntry poolRef(String name) {
return pool.equals(DEFAULT_POOL) ? DAILY_POOL : new RewardEntry(0, pool, List.of(), List.of());
}
- // The pool this drop or group draws from, or null when it is a real reward
public static String referencedPool(RewardEntry entry) {
return entry.weight() == 0 && entry.commands().isEmpty() && entry.items().isEmpty()
? entry.display()
@@ -183,16 +94,11 @@ public static String referencedPool(RewardEntry entry) {
private volatile int barMax;
private volatile int dailyMax;
- // Percent of dailyMax only the vote activity can fill, 0..100
private volatile int voteShare;
- // Ascending, deduped, every value inside 1..barMax. Never empty.
private volatile List milestones = List.of();
private volatile int barLength;
- // 0 disables the reroll button for everyone
private volatile int rerollsPerDay;
- // Highest dailyPoints a player may still reroll at. 0 means only before
- // anything has been earned today
private volatile int rerollMaxPoints;
private String goalCompleteSound;
@@ -200,25 +106,8 @@ public static String referencedPool(RewardEntry entry) {
private int saveIntervalMinutes;
- // ====================================
- // Shortest gap between two runs of an activity's 'click-commands' for one
- // player. The GUI is the only path where a click dispatches a console
- // command, so this is what keeps a held mouse button or a macro from
- // dispatching at click rate.
- // volatile for the same reason as the fields above it: /activity reload
- // rewrites it on the main thread.
- // ====================================
private volatile int clickCommandCooldownMillis = CLICK_COMMAND_COOLDOWN_DEFAULT;
- // ====================================
- // The two limits the per-player cooldown cannot give: how many click
- // dispatches the whole server may do in a second (the cooldown is per
- // player, so a hundred players holding a mouse button is a hundred
- // console dispatches a second on the main thread), and how many commands
- // one click may dispatch (the cooldown counts a click, not a command, so
- // a 20-entry 'click-commands' list multiplies the per-click cost).
- // volatile for the same reason as the field above.
- // ====================================
private volatile int clickCommandsPerSecond = CLICK_COMMANDS_PER_SECOND_DEFAULT;
private volatile int clickCommandsPerClick = CLICK_COMMANDS_PER_CLICK_DEFAULT;
@@ -248,8 +137,6 @@ public void load() {
resetDay = parseDay(config.getString("reset.day", "MONDAY"));
resetHour = Math.max(0, Math.min(23, config.getInt("reset.hour", 0)));
- // Reset here rather than in loadActivities: the one warning about m.
- // paths is logged after the section is read
pluginPathConfigured = false;
itemsAdderPathConfigured = false;
loadActivities(config.getConfigurationSection("activities"));
@@ -261,23 +148,14 @@ public void load() {
dailyMax = Math.max(1, config.getInt("bar.daily-max", 10));
voteShare = parseVoteShare(config);
warnIfVoteShareMisfits();
- // After barMax: every milestone is validated against it
milestones = loadMilestones(config.getIntegerList("bar.milestones"));
- // After milestones: drop_N names the Nth of them. Before the path
- // warnings below: a drop's m. or ia. path counts towards them.
milestoneDrops = loadMilestoneDrops(config);
if (poolNeededButEmpty(rewardPool(), milestoneDrops, milestones)) {
plugin.getLogger().warning("rewards.pool is empty or every entry in it was dropped - a milestone"
+ " can be reached but nothing can ever be claimed.");
}
- // Before the path warnings below, for the same reason as the drops
dailyRewards = loadDailyRewards(config);
- // ====================================
- // One line for the whole file, and only when config.yml actually asks
- // for an m. path: the icons and the craft keys all fail for the same
- // reason, and repeating it per entry buried the rest of the startup log.
- // ====================================
String itemPathProblem = itemPathWarning(pluginPathConfigured || !craftPaths.isEmpty(),
itemPathsUsable, missingItemPathPlugins);
if (itemPathProblem != null) {
@@ -308,12 +186,8 @@ public void load() {
clickCommandsPerClick = clamped(config, CLICK_COMMANDS_PER_CLICK_PATH,
CLICK_COMMANDS_PER_CLICK_DEFAULT, 1, 50);
- // 0 disables the idle check, so unlike the other clamps this one has
- // no lower bound of 1
afkMinutes = Math.max(0, config.getInt("playtime.afk-minutes", 5));
- // The timer credits the hardcoded id 'playtime', so renaming that
- // activity kills the feature without touching this section
if (config.contains("playtime.afk-minutes") && !activities.containsKey("playtime")) {
plugin.getLogger().warning("playtime.afk-minutes is set but there is no 'playtime' activity"
+ " - no minute will ever be credited.");
@@ -322,56 +196,26 @@ public void load() {
warnIfBarUnreachable();
}
- // ====================================
- // The two reroll knobs, parsed apart from load() so a headless test can
- // feed them a value and catch a typo in the path: the shipped defaults
- // are identical to the getInt fallbacks, so nothing else here would.
- // The paths are constants for the same reason - DefaultResourcesTest
- // asserts the shipped file against these exact strings.
- // ====================================
static final String REROLLS_PER_DAY_PATH = "reroll.per-day";
static final String REROLL_MAX_POINTS_PATH = "reroll.max-points";
- // The reward keys, constants for the same reason: the shipped multiplier
- // is 1, which is also the fallback, so a typo in either path would leave
- // every claim paying 1x with nothing to show for it.
public static final String REWARDS_PATH = "rewards";
static final String REWARDS_MULTIPLIER_PATH = "rewards.multiplier";
static final String REWARDS_POOL_PATH = REWARDS_PATH + "." + DEFAULT_POOL;
static final String REWARDS_DROPS_PATH = "rewards.drops";
static final String DAILY_REWARD_GROUPS_PATH = "daily-reward.groups";
- // The per-activity key and the rate limit that guards it. A path constant
- // for the same reason as the ones above: DefaultResourcesTest asserts the
- // shipped file against this exact string.
static final String CLICK_COMMANDS_KEY = "click-commands";
- // The optional per-activity blurb, a constant for the same reason:
- // DefaultResourcesTest asserts the shipped file against this exact string.
static final String DESCRIPTION_KEY = "description";
static final String CLICK_COMMAND_COOLDOWN_PATH = "click-command-cooldown-millis";
static final String CLICK_COMMANDS_PER_SECOND_PATH = "click-commands-per-second";
static final String CLICK_COMMANDS_PER_CLICK_PATH = "click-commands-per-click";
- // ====================================
- // The defaults, named once each: they are both the field initialiser and
- // what an absent or unparseable value falls back to, and writing the
- // number in three places is how "absent" and "unreadable" end up meaning
- // two different things.
- // ====================================
static final int CLICK_COMMAND_COOLDOWN_DEFAULT = 1000;
static final int CLICK_COMMANDS_PER_SECOND_DEFAULT = 20;
static final int CLICK_COMMANDS_PER_CLICK_DEFAULT = 5;
- // ====================================
- // The three click-command knobs, parsed the same way. None of them has an
- // "off" value the reroll knobs have: 0 would let a held mouse button
- // dispatch console commands at click rate, so each has a floor above
- // zero, and a ceiling past which the value reads as a typo rather than a
- // setting. A non-number is named and the default used, the way
- // rewardMultiplier() names one. Takes the section, not the value, so a
- // headless test can catch a typo in the path.
- // ====================================
private int clamped(ConfigurationSection config, String path, int fallback, int min, int max) {
Object raw = config.get(path);
if (raw == null) {
@@ -392,14 +236,10 @@ private int clamped(ConfigurationSection config, String path, int fallback, int
return (int) value;
}
- // 0 turns rerolling off entirely, so like playtime.afk-minutes this clamp
- // has no lower bound of 1
static int parseRerollsPerDay(ConfigurationSection config) {
return Math.max(0, config.getInt(REROLLS_PER_DAY_PATH, 1));
}
- // 0 is a meaningful setting here too - it allows a reroll only while
- // nothing has been earned today - so a negative clamps down to it
static int parseRerollMaxPoints(ConfigurationSection config) {
return Math.max(0, config.getInt(REROLL_MAX_POINTS_PATH, 1));
}
@@ -445,10 +285,6 @@ private void loadActivities(ConfigurationSection section) {
loaded.put(id, new ActivityDef(
id,
entry.getString("display", id),
- // Still PAPER behind a path: what the GUI shows if TLibs is
- // gone or the path stops resolving. iconPath() has already
- // reported a path that could not be used, so material() -
- // which would call it an unknown material - is skipped.
ItemPath.isPluginPath(iconValue) || ItemPath.isItemsAdderPath(iconValue)
? Material.PAPER
: material(iconValue, "activities." + id + ".material"),
@@ -470,11 +306,6 @@ private void loadActivities(ConfigurationSection section) {
plugin.getLogger().warning("Activities '" + previous + "' and '" + id
+ "' both track profession '" + key + "' - only '" + id + "' will be fed.");
}
- // ====================================
- // Profession XP is game-influenced and can be hundreds per
- // event, unlike the one-per-action activities, so an
- // uncapped profession activity is an unbounded reward source.
- // ====================================
if (dailyCap <= 0) {
plugin.getLogger().warning("Activity '" + id + "' tracks profession '" + key
+ "' with no daily-cap - profession XP is unbounded, so this activity can"
@@ -489,12 +320,6 @@ private void loadActivities(ConfigurationSection section) {
}
}
- // ====================================
- // More guaranteed activities than there are task slots: the draw can
- // only hold TASKS_PER_DAY of them, so every other activity in the file
- // becomes undrawable. Worth one line at load - it is almost certainly
- // not what the admin meant.
- // ====================================
if (guaranteed.size() > PlayerData.TASKS_PER_DAY) {
plugin.getLogger().warning(guaranteed.size() + " activities are marked daily-guaranteed but only "
+ PlayerData.TASKS_PER_DAY + " tasks are handed out a day - every draw is "
@@ -502,7 +327,6 @@ private void loadActivities(ConfigurationSection section) {
+ " ever be drawn.");
}
- // One assignment publishes the whole set
activities = loaded;
craftActivities = crafts;
craftPaths = List.copyOf(paths);
@@ -511,12 +335,6 @@ private void loadActivities(ConfigurationSection section) {
guaranteedActivities = List.copyOf(guaranteed);
}
- // ====================================
- // Optional 'station: ' or 'station: /'. An
- // activity already fed by 'craft:' or 'profession:' would be fed by two
- // unrelated sources at once, so the extra 'station:' is reported and
- // dropped and the first-declared feed is what stays.
- // ====================================
private void loadStation(Map stations, ConfigurationSection entry, String id) {
String station = entry.getString("station");
if (station == null) {
@@ -559,15 +377,11 @@ private void loadStation(Map stations, ConfigurationSection entr
}
}
- // Station and recipe ids are matched case-insensitively and trimmed, on
- // both sides, so 'Ingot-Station / Flint' and 'ingot-station/flint' are
- // the same key. More than one slash is rejected as malformed.
private static String stationKey(String station) {
int slash = station.indexOf('/');
if (slash < 0) {
return normalizeStationPart(station);
}
- // Reject values with more than one slash
if (station.indexOf('/', slash + 1) >= 0) {
return "";
}
@@ -579,11 +393,6 @@ private static String normalizeStationPart(String part) {
return part.trim().toLowerCase(Locale.ROOT);
}
- // ====================================
- // Optional. A name that is not a material is the admin's typo, not a
- // reason to lose the rest of config.yml - say which key is wrong and
- // leave the activity in place, just with nothing feeding it.
- // ====================================
private void loadCraft(Map crafts, List> paths,
String name, String id) {
String problem = registerCraft(crafts, paths, name, id, ActivityConfiguration::craftableItem);
@@ -592,39 +401,15 @@ private void loadCraft(Map crafts, List crafts, List> paths,
String name, String id, Predicate craftable) {
if (name == null || name.isBlank()) {
return null;
}
- // The name and the section key it sits under are both admin-supplied
- // and go straight into a log line
String safe = Utils.safeForLog(name);
String safeId = Utils.safeForLog(id);
- // An m.. item cannot be keyed by Material - it is matched
- // by asking TLibs, so it is kept as the path it was written as. Same
- // first-one-wins rule as the materials below.
if (ItemPath.isPluginPath(name)) {
String path = ItemPath.pluginPath(name);
if (path == null) {
@@ -643,19 +428,6 @@ static String registerCraft(Map crafts, List is not supported for craft keys (only for 'material:'"
@@ -693,11 +465,6 @@ private static boolean craftableItem(Material material) {
return !material.isAir() && material.isItem();
}
- // ====================================
- // A quoted or misspelled number reads as 0 through getInt, which would
- // silently make an activity worthless or its goal met on the first action.
- // Name the activity and the key rather than guessing quietly.
- // ====================================
private int wholeNumber(ConfigurationSection entry, String id, String key, int fallback) {
if (entry.contains(key) && !entry.isInt(key)) {
plugin.getLogger().warning("activities." + id + "." + key + " is not a whole number ('"
@@ -707,11 +474,6 @@ private int wholeNumber(ConfigurationSection entry, String id, String key, int f
return entry.getInt(key, fallback);
}
- // ====================================
- // Optional boolean activity key. A value that is not a boolean is the
- // admin's typo - it is named and the fallback is used, the same way
- // wholeNumber() handles a non-number.
- // ====================================
private boolean flag(ConfigurationSection entry, String id, String key, boolean fallback) {
if (entry.contains(key) && !entry.isBoolean(key)) {
plugin.getLogger().warning("activities." + id + "." + key + " is not true or false ('"
@@ -721,12 +483,6 @@ private boolean flag(ConfigurationSection entry, String id, String key, boolean
return entry.getBoolean(key, fallback);
}
- // ====================================
- // Optional per-activity console commands, run when the player clicks an
- // already-revealed task. A single 'click-commands: sudo %player% votelist'
- // is the natural typo and would otherwise be dropped without a word - the
- // same bug loadRewardItems refuses to have.
- // ====================================
private List clickCommands(ConfigurationSection entry, String id) {
Object raw = entry.get(CLICK_COMMANDS_KEY);
if (raw == null) {
@@ -741,12 +497,6 @@ private List clickCommands(ConfigurationSection entry, String id) {
return nonBlank(list);
}
- // ====================================
- // Optional per-activity blurb, shown at the top of the task's lore once
- // the task is revealed. A two-line description wants a list and a one-line
- // one wants a plain string, so both are accepted; anything else (a number,
- // a nested section) is named and ignored rather than silently dropped.
- // ====================================
private List description(ConfigurationSection entry, String id) {
Object raw = entry.get(DESCRIPTION_KEY);
if (raw == null) {
@@ -764,8 +514,6 @@ private List description(ConfigurationSection entry, String id) {
return List.of();
}
- // Every blank or missing entry dropped, so neither a stray '- ""' nor a
- // null from YAML reaches the GUI or the dispatcher
private static List nonBlank(List> list) {
List kept = new ArrayList<>();
for (Object value : list) {
@@ -776,8 +524,6 @@ private static List nonBlank(List> list) {
return List.copyOf(kept);
}
- // A bar of 0 glyphs is invisible and one of 5000 does not fit in a lore
- // line, so the value is pinned to something that can actually be rendered
private int barLength(int length) {
if (length < 1 || length > 100) {
int clamped = Math.max(1, Math.min(100, length));
@@ -787,15 +533,6 @@ private int barLength(int length) {
return length;
}
- // ====================================
- // Bukkit hands the jar's packaged config.yml to the server's own as
- // defaults, so config.get*("rewards.pool") answers with the packaged pool
- // on a server whose file has no such key - which would have it silently
- // pay rewards nobody configured. Everything under rewards: and
- // daily-reward: is therefore read through these two: contains(path, true)
- // ignores the defaults, and a section's getKeys(false) merges the
- // defaults' keys in, so every key is checked the same way.
- // ====================================
private static boolean set(ConfigurationSection config, String path) {
return config.contains(path, true);
}
@@ -814,11 +551,6 @@ private static List keys(ConfigurationSection section) {
return keys;
}
- // ====================================
- // Every pool under rewards:, by name: 'pool' and any 'pool_', each
- // read exactly the same way. A key that is not a pool (multiplier, drops)
- // is left to its own parser.
- // ====================================
private Map> loadRewardPools(ConfigurationSection config) {
ConfigurationSection rewards = section(config, REWARDS_PATH);
if (rewards == null) {
@@ -844,13 +576,6 @@ private Map> loadRewardPools(ConfigurationSection conf
return Map.copyOf(pools);
}
- // ====================================
- // One reward pool. Every entry is validated on its own: a bad weight or an
- // entry with nothing to run is dropped with a warning naming the pool
- // rather than taking the rest of it down with it. Weights are clamped so a
- // pool of absurd numbers cannot overflow the cumulative total the draw
- // walks.
- // ====================================
private List loadRewardPool(ConfigurationSection config, String path) {
List pool = new ArrayList<>();
if (!set(config, path)) {
@@ -890,24 +615,11 @@ private List loadRewardPool(ConfigurationSection config, String pat
return List.copyOf(pool);
}
- // ====================================
- // The items one pool entry hands over. A path that cannot possibly work -
- // an unknown material, another plugin's path syntax, a malformed
- // m.. - is dropped here with a warning rather than kept to fail
- // at payout, because a broken path at payout costs the player a click and
- // a 'reward-failed'. A well formed m. path is kept even when TLibs is not
- // enabled right now: nothing here can tell a deleted MMOItems id from one
- // that resolves fine once the server has all three plugins, so that one is
- // left to fail loudly at payout instead.
- // ====================================
private List loadRewardItems(Object raw, String where) {
List items = new ArrayList<>();
if (raw == null) {
return items;
}
- // A single 'items: DIAMOND' or 'items: {item: DIAMOND}' is the natural
- // typo, and silently paying nothing for it is exactly what every other
- // malformed key here refuses to do
if (!(raw instanceof List> list)) {
plugin.getLogger().warning(where + ".items is not a list of 'item:'/'amount:' blocks ('"
+ Utils.safeForLog(String.valueOf(raw)) + "') - no item is handed over for this entry.");
@@ -934,9 +646,6 @@ private List loadRewardItems(Object raw, String where) {
return items;
}
- // The forms ItemPath accepts, warned about the way material() and
- // iconPath() warn - except that a reward item has no safe default, so a
- // value that is none of them is dropped instead of falling back.
private boolean validItemPath(String value, String at) {
if (ItemPath.isUnsupportedPath(value)) {
plugin.getLogger().warning("Unsupported item path '" + Utils.safeForLog(value) + "' at " + at
@@ -950,11 +659,6 @@ private boolean validItemPath(String value, String at) {
+ " - expected ia. - ignored.");
return false;
}
- // Counts as "the admin asked for ItemsAdder items", so load() warns
- // once for the file when ItemsAdder is not enabled. Kept as written:
- // resolveRewardItem normalizes it when the payout happens, and a
- // warning that quotes config.yml back is worth more than one that
- // quotes something the admin never typed.
itemsAdderPathConfigured = true;
return true;
}
@@ -964,8 +668,6 @@ private boolean validItemPath(String value, String at) {
+ " - expected m.. - ignored.");
return false;
}
- // Counts as "the admin asked for item paths", so load() warns once
- // for the file when TLibs/MMOItems/MythicLib are not all enabled
pluginPathConfigured = true;
return true;
}
@@ -977,9 +679,6 @@ private boolean validItemPath(String value, String at) {
return true;
}
- // 64 is a vanilla stack and the most one 'items:' line may hand over;
- // anything else (absent, negative, text) falls back to 1 the way every
- // other numeric key here clamps rather than skips.
private int rewardAmount(Object raw, String at) {
if (raw == null) {
return 1;
@@ -989,10 +688,6 @@ private int rewardAmount(Object raw, String at) {
+ "' - using 1.");
return 1;
}
- // Tested as a long before narrowing: intValue() on 4294967298 is 2,
- // which would pass the range test having asked for something else
- // entirely. A fractional amount is a different mistake and falls back
- // rather than silently rounding.
long amount = number.longValue();
if (number.doubleValue() != amount) {
plugin.getLogger().warning(at + " amount '" + Utils.safeForLog(String.valueOf(raw))
@@ -1007,20 +702,6 @@ private int rewardAmount(Object raw, String at) {
return (int) amount;
}
- // ====================================
- // rewards.multiplier: how many times a pool milestone is spun at payout,
- // and what a fixed drop's amount is multiplied by. 1-64 - 0 or negative
- // would mean "hand nothing over", which is never what an admin meant (the
- // way to pay nothing is to drop the entry), and 64 spins or 64 x a 64
- // amount is already plenty off one claim.
- //
- // Read raw rather than through getInt, and refused the same way an
- // 'amount:' is: getInt turns 2.9 into 2 in silence and reports a
- // non-numeric value as "0 is outside 1-64", naming a number the admin
- // never wrote. Takes the section, not the value, so a headless test can
- // catch a typo in the path - the shipped value is 1, which is also the
- // fallback, so nothing else would.
- // ====================================
private int rewardMultiplier(ConfigurationSection config) {
Object raw = config.get(REWARDS_MULTIPLIER_PATH);
if (raw == null) {
@@ -1031,8 +712,6 @@ private int rewardMultiplier(ConfigurationSection config) {
+ Utils.safeForLog(String.valueOf(raw)) + "') - using 1.");
return 1;
}
- // Long before narrowing, and a fraction refused rather than rounded,
- // exactly as rewardAmount does it
long value = number.longValue();
if (number.doubleValue() != value) {
plugin.getLogger().warning(REWARDS_MULTIPLIER_PATH + " '" + Utils.safeForLog(String.valueOf(raw))
@@ -1048,15 +727,6 @@ private int rewardMultiplier(ConfigurationSection config) {
return (int) value;
}
- // ====================================
- // rewards.drops: 'drop_N: pool', 'drop_N: pool_' or
- // 'drop_N: - [amount]' for the Nth milestone. Anything that does
- // not parse is named and left to the default pool, so a typo never pays
- // less than the config did before drops existed. The path is checked the
- // way a pool item's is - a well formed m. or ia. path is kept even when its
- // plugin is not up yet and is left to resolve, or fail and stay claimable,
- // at payout.
- // ====================================
private Map loadMilestoneDrops(ConfigurationSection config) {
ConfigurationSection section = section(config, REWARDS_DROPS_PATH);
if (section == null) {
@@ -1074,8 +744,6 @@ private Map loadMilestoneDrops(ConfigurationSection config
plugin.getLogger().warning(at + " is not a drop_ key (N = 1, 2, ...) - ignored.");
continue;
}
- // Length first, so a number too long for an int is out of range
- // rather than a NumberFormatException
if (number.length() > 9 || Integer.parseInt(number) > milestones.size()) {
plugin.getLogger().warning(at + " is past the last of the " + milestones.size()
+ " bar.milestones - ignored.");
@@ -1085,8 +753,6 @@ private Map loadMilestoneDrops(ConfigurationSection config
Object raw = section.get(key);
if (raw instanceof String string && isPoolName(string)) {
- // The default pool is what a milestone with no drop at all
- // draws from, so 'pool' is left out of the map entirely
if (poolName(string).equals(DEFAULT_POOL)) {
continue;
}
@@ -1102,14 +768,6 @@ private Map loadMilestoneDrops(ConfigurationSection config
return Map.copyOf(drops);
}
- // ====================================
- // A drop or group value that names a pool. A name no pool under rewards:
- // matches, or one whose every entry was dropped, is warned about here and
- // still kept as a reference: falling back to the default pool would pay a
- // reward the admin never listed for it, and parsing 'pool_skins' as an
- // item path would only warn about a material nobody meant. It pays
- // nothing until the pool is there.
- // ====================================
private RewardEntry warnedPoolRef(String at, String value, String otherwise) {
String pool = poolName(value);
if (rewardPool(pool).isEmpty()) {
@@ -1119,13 +777,6 @@ private RewardEntry warnedPoolRef(String at, String value, String otherwise) {
return poolRef(pool);
}
- // ====================================
- // The value at a rewards.drops.drop_N or daily-reward.groups. key,
- // either the one-line "
- [amount]" string or a block
- // '{item: , amount: }' - a nested map loads as a
- // ConfigurationSection the same way an activity entry does. Dispatches to
- // whichever form was written and shares the rest of the parsing.
- // ====================================
private RewardEntry fixedItem(String at, Object raw, String otherwise) {
if (raw instanceof ConfigurationSection block) {
return fixedItemBlock(at, block, otherwise);
@@ -1133,13 +784,8 @@ private RewardEntry fixedItem(String at, Object raw, String otherwise) {
return fixedItemString(at, String.valueOf(raw).strip(), otherwise);
}
- // ====================================
- // The one-line "
- [amount]" form, or null when it does not
- // parse - named in the log along with 'otherwise', what happens instead.
- // ====================================
private RewardEntry fixedItemString(String at, String value, String otherwise) {
String[] parts = value.split("\\s+");
- // 0 marks anything that is not one or two words with a 1-64 second one
int amount = parts.length == 1 ? 1
: parts.length == 2 && parts[1].matches("[0-9]{1,2}") ? Integer.parseInt(parts[1]) : 0;
if (amount < 1 || amount > 64) {
@@ -1151,18 +797,9 @@ private RewardEntry fixedItemString(String at, String value, String otherwise) {
plugin.getLogger().warning(at + " has no usable item path - " + otherwise + ".");
return null;
}
- // display is the bare item name: the payout prefixes the amount it
- // actually hands over, multiplier included
return new RewardEntry(1, itemName(parts[0]), List.of(), List.of(new RewardEntry.Item(parts[0], amount)));
}
- // ====================================
- // The block form: 'item:' required, 'amount:' optional (default 1) and
- // validated the same 1-64 way the string form's second word is - it may
- // come in as a YAML int or a quoted string, so both are read back through
- // the same digit check. An unknown extra key only gets its own warning;
- // it does not fail the entry the way a bad 'item:' or 'amount:' does.
- // ====================================
private RewardEntry fixedItemBlock(String at, ConfigurationSection block, String otherwise) {
String path = block.getString("item");
if (path == null || path.isBlank()) {
@@ -1199,13 +836,6 @@ private RewardEntry fixedItemBlock(String at, ConfigurationSection block, String
return new RewardEntry(1, itemName(path), List.of(), List.of(new RewardEntry.Item(path, amount)));
}
- // ====================================
- // daily-reward.groups: ': pool', ': pool_' or
- // ':
- [amount]', in config order - the first group a
- // player is in wins, so the highest rank goes first. A value that does not
- // parse is named and that group gets nothing. Read after the pools, which
- // a pool group draws from.
- // ====================================
private Map loadDailyRewards(ConfigurationSection config) {
ConfigurationSection section = section(config, DAILY_REWARD_GROUPS_PATH);
if (section == null) {
@@ -1219,8 +849,6 @@ private Map loadDailyRewards(ConfigurationSection config) {
for (String group : keys(section)) {
Object raw = section.get(group);
if (raw instanceof String string && isPoolName(string)) {
- // The default pool's own "it is empty" warning is the one
- // below, which names every group at once
groups.put(group, poolName(string).equals(DEFAULT_POOL)
? DAILY_POOL
: warnedPoolRef(DAILY_REWARD_GROUPS_PATH + "." + Utils.safeForLog(group), string,
@@ -1241,17 +869,11 @@ private Map loadDailyRewards(ConfigurationSection config) {
return Collections.unmodifiableMap(groups);
}
- // The load-time "nothing can ever be claimed" check: only true when some
- // milestone actually draws from the empty default pool. A milestone whose
- // drop names another pool has been warned about by warnedPoolRef already.
static boolean poolNeededButEmpty(List pool, Map drops,
List milestones) {
return pool.isEmpty() && ActivityManager.neededPools(milestones, drops).contains(DEFAULT_POOL);
}
- // The chat name of a fixed drop, from its path alone - resolving it here
- // would need TLibs/ItemsAdder up at load. The last segment, words
- // capitalised: m.material.steel is "Steel", ia.tfmc:ruby_gem "Ruby Gem".
static String itemName(String path) {
String last = path.substring(Math.max(path.lastIndexOf('.'), path.lastIndexOf(':')) + 1);
StringBuilder name = new StringBuilder();
@@ -1264,12 +886,6 @@ static String itemName(String path) {
return name.toString();
}
- // ====================================
- // The point totals a reward can be claimed at. Sorted and deduped, since
- // claim() walks them in order and pays each one once; anything outside the
- // bar is dropped, because a milestone past bar.max can never be reached
- // and one at 0 would be claimable before anything was done.
- // ====================================
private List loadMilestones(List raw) {
TreeSet milestones = new TreeSet<>();
for (Integer milestone : raw) {
@@ -1289,7 +905,6 @@ private List loadMilestones(List raw) {
milestones.add(fallback);
}
}
- // A bar too small for either default still needs one reward on it
if (milestones.isEmpty()) {
milestones.add(barMax);
}
@@ -1297,11 +912,6 @@ private List loadMilestones(List raw) {
return List.copyOf(milestones);
}
- // ====================================
- // bar.vote-share only makes sense when voting can actually fill what it
- // keeps back: a vote activity every player is handed each day, with a
- // daily-cap that leaves room for the whole reserved share.
- // ====================================
private void warnIfVoteShareMisfits() {
if (voteShare == 0) {
return;
@@ -1324,18 +934,6 @@ private void warnIfVoteShareMisfits() {
}
}
- // ====================================
- // A player can only earn from the TASKS_PER_DAY activities drawn for them,
- // so the daily ceiling is the worst draw they can get: the lowest
- // TASKS_PER_DAY daily-caps in points, itself capped by bar.daily-max. Seven days of
- // that is the weekly ceiling - if it is under the first milestone, an
- // unlucky week can never be claimed on.
- //
- // Too few capped activities to fill a draw means an uncapped one is always
- // in it, and then only bar.daily-max bounds the day. Fewer loaded
- // activities than TASKS_PER_DAY still bounds, since the draw is then all
- // of them.
- // ====================================
private void warnIfBarUnreachable() {
List caps = new ArrayList<>();
for (ActivityDef def : activities.values()) {
@@ -1344,8 +942,6 @@ private void warnIfBarUnreachable() {
}
}
- // Counted for a player who never votes: with a vote activity loaded,
- // bar.vote-share keeps part of the day out of their reach
boolean shared = voteShare > 0 && activities.containsKey("vote");
String warning = unreachableWarning(caps, activities.size(), shared ? nonVoteDailyMax() : dailyMax,
milestones.get(0));
@@ -1355,16 +951,6 @@ private void warnIfBarUnreachable() {
}
}
- // ====================================
- // The warning text, or null when the first reward is reachable. Pure so
- // both the arithmetic and what it says about it can be tested.
- //
- // The bound is named off what the caps alone would allow, so a sum that
- // lands exactly on bar.daily-max is not reported as bound by daily-max
- // alone - both numbers have to change to lift it. The task count is the
- // draw's real size, which is every loaded activity when fewer than
- // TASKS_PER_DAY are loaded.
- // ====================================
static String unreachableWarning(List dailyCaps, int activityCount, int dailyMax,
int firstMilestone) {
long capCeiling = dailyCeiling(dailyCaps, activityCount, Integer.MAX_VALUE);
@@ -1383,17 +969,10 @@ static String unreachableWarning(List dailyCaps, int activityCount, int
+ " cannot count on reaching the first reward at " + firstMilestone + ".";
}
- // ====================================
- // The arithmetic above, pure so it can be tested: the daily-caps, in
- // points, of the capped activities (in any order), how many activities are loaded in
- // total, and bar.daily-max. Package-private for the test.
- // ====================================
static long dailyCeiling(List dailyCaps, int activityCount, int dailyMax) {
List caps = new ArrayList<>(dailyCaps);
caps.sort(null);
- // Too few capped activities to fill a draw on their own: every draw
- // holds at least one uncapped activity, so only bar.daily-max bounds it
if (caps.size() < PlayerData.TASKS_PER_DAY && caps.size() != activityCount) {
return dailyMax;
}
@@ -1405,12 +984,6 @@ static long dailyCeiling(List dailyCaps, int activityCount, int dailyMa
return Math.min(sum, dailyMax);
}
- // ====================================
- // The m.. or ia. path an icon should be built
- // from, or null when the value is a plain material - which material() then
- // reports on as before. An ia. path comes back normalized to its colon
- // form, so the two ways of writing it are one path from here on.
- // ====================================
private String iconPath(String name, String path) {
if (ItemPath.isItemsAdderPath(name)) {
String id = ItemPath.itemsAdderId(name);
@@ -1419,10 +992,6 @@ private String iconPath(String name, String path) {
+ " - expected ia. - using PAPER.");
return null;
}
- // Well formed, so it counts even when ItemsAdder cannot resolve it -
- // load() says that once for the whole file. Kept as a path rather
- // than gated here the way the m. branch is, because the GUI is what
- // asks ItemsAdder and it re-checks itemsAdderUsable() at build time.
itemsAdderPathConfigured = true;
return "ia." + id;
}
@@ -1435,9 +1004,6 @@ private String iconPath(String name, String path) {
+ " - expected m.. - using PAPER.");
return null;
}
- // Well formed, so it counts as "the admin asked for item paths" even
- // when nothing can resolve it - loadActivities says that once, for the
- // whole file, instead of once per icon.
pluginPathConfigured = true;
return itemPathsUsable ? resolved : null;
}
@@ -1466,11 +1032,6 @@ private DayOfWeek parseDay(String name) {
}
}
- // ====================================
- // Sounds are played by key rather than by the Sound enum: the enum's
- // constants move between Minecraft versions, the keys do not. Admins can
- // write either ENTITY_EXPERIENCE_ORB_PICKUP or entity.experience.orb.pickup.
- // ====================================
private String soundKey(String name) {
if (name == null || name.isBlank()) {
return null;
@@ -1490,22 +1051,10 @@ public ActivityDef activity(String id) {
return activities.get(id);
}
- // The ids marked 'daily-guaranteed', in config order. Every one of them
- // is in every player's draw for the day.
public List guaranteed() {
return guaranteedActivities;
}
- // ====================================
- // The activity fed by crafting this item, or null if none is. The
- // Material map answers first and answers almost every craft; the item
- // paths are only walked when it missed and there are any.
- //
- // ponytail: an MMOItems result whose base Material is also claimed by a
- // vanilla 'craft:' is credited to the vanilla activity, because the map
- // answers first. Upgrade path if that combination is ever configured:
- // check the paths before the map when both claim that Material.
- // ====================================
public String craftActivity(ItemStack crafted) {
String id = craftActivities.get(crafted.getType());
if (id != null || craftPaths.isEmpty() || !itemPathsUsable) {
@@ -1514,11 +1063,6 @@ public String craftActivity(ItemStack crafted) {
return TLibsItems.match(crafted, craftPaths);
}
- // ====================================
- // The activity fed by crafting at MMOItems station
- // , if any. A '/' activity wins over a
- // whole-station one, so a craft only ever feeds a single activity.
- // ====================================
public Optional stationActivity(String stationId, String recipeId) {
if (stationId == null || stationId.isBlank()) {
return Optional.empty();
@@ -1533,7 +1077,6 @@ public Optional stationActivity(String stationId, String recipeId) {
return Optional.ofNullable(stationActivities.get(station));
}
- // The activity fed by an MMOCore profession, or null if none tracks it
public String professionActivity(String professionId) {
return professionActivity(professionActivities, professionId);
}
@@ -1545,28 +1088,14 @@ static String professionActivity(Map professions, String profess
return professions.get(normalizeProfessionId(professionId));
}
- // ====================================
- // MMOCore's Profession constructor stores its id as
- // lowercase-with-underscores-and-spaces-turned-into-dashes, so a file
- // named mining_expert.yml has the id 'mining-expert'. config.yml tells
- // admins to use the file name, so both the map keys and the lookups run
- // through here - anything else silently never matches.
- // ====================================
static String normalizeProfessionId(String professionId) {
return professionId.trim().toLowerCase(Locale.ROOT).replace('_', '-').replace(' ', '-');
}
- // How long a player must have been idle before a minute stops counting.
- // 0 means the idle check is off and every online minute counts.
public int afkMinutes() {
return afkMinutes;
}
- // ====================================
- // Both keys off one clock reading. Asking for them separately can straddle
- // a midnight tick and produce a day key from the new day with a week key
- // from the old one, which reads as a rollover that never happened.
- // ====================================
public Keys currentKeys() {
LocalDateTime now = LocalDateTime.now();
return new Keys(Weeks.weekKey(now, resetDay, resetHour), Weeks.dayKey(now.toLocalDate()));
@@ -1587,7 +1116,6 @@ public List rewardPool() {
return rewardPool(DEFAULT_POOL);
}
- // A pool by name; empty for a name no rewards: key matched
public List rewardPool(String name) {
return rewardPools.getOrDefault(poolName(name), List.of());
}
@@ -1596,12 +1124,10 @@ public int rewardMultiplier() {
return rewardMultiplier;
}
- // Keyed by milestone point total; a milestone missing here draws from the pool
public Map milestoneDrops() {
return milestoneDrops;
}
- // Group name -> daily reward, in config order: the first match wins
public Map dailyRewards() {
return dailyRewards;
}
@@ -1618,18 +1144,14 @@ public int dailyMax() {
return dailyMax;
}
- // Most points every activity but vote can add together in one day
public int nonVoteDailyMax() {
return nonVoteDailyMax(dailyMax, voteShare);
}
- // Rounded down, so the share kept for voting rounds up
static int nonVoteDailyMax(int dailyMax, int voteShare) {
return dailyMax * (100 - voteShare) / 100;
}
- // bar.vote-share, clamped to 0-100 with a warning. Package-private for
- // the test, which has no server to run load() on.
int parseVoteShare(ConfigurationSection config) {
return clamped(config, "bar.vote-share", 50, 0, 100);
}
@@ -1642,15 +1164,10 @@ public int barLength() {
return barLength;
}
- // How many times a day a player may throw today's draw away. 0 means the
- // reroll button refuses everyone.
public int rerollsPerDay() {
return rerollsPerDay;
}
- // The most points a player may already have banked today and still be
- // allowed to reroll. 0 means the reroll is only offered before anything
- // has been earned.
public int rerollMaxPoints() {
return rerollMaxPoints;
}
@@ -1667,37 +1184,26 @@ public int saveIntervalMinutes() {
return saveIntervalMinutes;
}
- // Shortest gap between two 'click-commands' runs for one player
public int clickCommandCooldownMillis() {
return clickCommandCooldownMillis;
}
- // Most click dispatches the whole server may do in a second
public int clickCommandsPerSecond() {
return clickCommandsPerSecond;
}
- // Most commands one click may dispatch
public int clickCommandsPerClick() {
return clickCommandsPerClick;
}
- // Whether an m.. path can actually be resolved right now -
- // TLibs, MMOItems and MythicLib all enabled. The GUI asks this instead
- // of re-checking isPluginEnabled("TLibs") on its own, so both places
- // agree on what "usable" means.
public boolean itemPathsUsable() {
return itemPathsUsable;
}
- // Whether an ia. path can actually be resolved right now.
- // Asked separately from itemPathsUsable() by the GUI and by the reward
- // payout, so ItemsAdder being absent costs ia. paths only.
public boolean itemsAdderUsable() {
return itemsAdderUsable;
}
- // The same for m. paths and the TLibs/MMOItems/MythicLib trio
static String itemPathWarning(boolean configured, boolean usable, String missing) {
if (!configured || usable) {
return null;
@@ -1708,9 +1214,6 @@ static String itemPathWarning(boolean configured, boolean usable, String missing
+ " hands over nothing and leaves its milestone unclaimed.";
}
- // The one line the whole file gets when it asks for ia. paths and
- // ItemsAdder is not enabled, or null when there is nothing to say. Split
- // out so the rule can be pinned without a running server behind load().
static String itemsAdderWarning(boolean configured, boolean usable) {
if (!configured || usable) {
return null;
diff --git a/src/main/java/tfmc/justin/activity/config/Messages.java b/src/main/java/tfmc/justin/activity/config/Messages.java
index 8144ef9..1086438 100644
--- a/src/main/java/tfmc/justin/activity/config/Messages.java
+++ b/src/main/java/tfmc/justin/activity/config/Messages.java
@@ -11,35 +11,18 @@
import java.io.Reader;
import java.nio.charset.StandardCharsets;
-// ====================================
-// Every string the plugin sends to chat, read from messages.yml.
-//
-// The packaged copy is installed as the defaults, so a key an admin deleted
-// still resolves to shipped text rather than showing a raw path in chat.
-// ====================================
public class Messages {
public static final String FILE = "messages.yml";
private final JavaPlugin plugin;
- // Volatile: /activity reload swaps it on the main thread while
- // PlaceholderAPI can be reading a message off one
private volatile YamlConfiguration messages;
- // Not loaded here: ActivityConfiguration.load() calls reload() before
- // anything can ask for a message, and parsing the file twice per enable
- // was the only thing the constructor call added
public Messages(JavaPlugin plugin) {
this.plugin = plugin;
}
- // ====================================
- // Re-read messages.yml from disk, so /activity reload picks up text edits.
- // Built entirely into a local 'next' and only assigned to the volatile
- // field as the last step, so a reader on another thread never observes it
- // between being loaded and having its defaults applied.
- // ====================================
public void reload() {
File file = new File(plugin.getDataFolder(), FILE);
if (!file.exists()) {
@@ -56,15 +39,9 @@ public void reload() {
messages = next;
}
- // ====================================
- // Look up a message and fill in its placeholders.
- // Pairs are given inline: get("admin.add-done", "%count%", n, "%player%", name)
- // ====================================
public String get(String path, Object... placeholderPairs) {
String raw = messages.getString(path);
if (raw == null) {
- // Only reachable if the key is missing from both the live file and
- // the packaged defaults, which means a typo in a call site
plugin.getLogger().warning("Missing message '" + path + "' in " + FILE);
return path;
}
@@ -74,11 +51,6 @@ public String get(String path, Object... placeholderPairs) {
return Utils.colorize(raw);
}
- // ====================================
- // Colour codes are translated before the values go in, so a value that
- // happens to contain '&c' or a hex code lands in chat as literal text
- // instead of recolouring the rest of the line.
- // ====================================
String message = Utils.colorize(raw);
for (int index = 0; index < placeholderPairs.length; index += 2) {
message = message.replace(String.valueOf(placeholderPairs[index]),
@@ -88,11 +60,6 @@ public String get(String path, Object... placeholderPairs) {
return message;
}
- // ====================================
- // Verbatim lookup: no colour translation, no placeholder substitution and
- // no warning on a miss, where get() logs one. The path is returned as-is
- // when the key resolves to nothing. Callers colorize what they get back.
- // ====================================
public String raw(String path) {
String value = messages.getString(path);
return value == null ? path : value;
diff --git a/src/main/java/tfmc/justin/activity/gui/ActivityGui.java b/src/main/java/tfmc/justin/activity/gui/ActivityGui.java
index 457803c..5cefa44 100644
--- a/src/main/java/tfmc/justin/activity/gui/ActivityGui.java
+++ b/src/main/java/tfmc/justin/activity/gui/ActivityGui.java
@@ -30,29 +30,15 @@
import java.util.List;
import java.util.function.Function;
-// ====================================
-// One single-chest view of the week: the two bars on the top row, today's
-// seven drawn tasks in the middle row, filler everywhere else. The weekly bar
-// is also the claim button: clicking it hands over whatever milestones are
-// due. A task starts hidden and is revealed by clicking it; only a revealed
-// task earns anything (the gate itself lives in ActivityManager). Also the
-// click listener - a marker holder is the cheapest way to tell our inventory
-// apart from every other one.
-// ====================================
public class ActivityGui implements Listener {
private static final int SIZE = 27;
private static final int DAILY_BAR_SLOT = 3;
- // Between the two bars, and shown to everyone: a player without the
- // permission sees what the perk is rather than nothing at all
private static final int REROLL_SLOT = 4;
private static final int BAR_SLOT = 5;
- // The middle row, one slot per daily task - PlayerData.TASKS_PER_DAY of
- // them, floating inside a border of filler
private static final int[] TASK_SLOTS = {10, 11, 12, 13, 14, 15, 16};
- // Marketblock's demand bar length - the per-activity bars match it
private static final int PROGRESS_BAR_LENGTH = 20;
private final ActivityManager manager;
@@ -61,12 +47,6 @@ public ActivityGui(ActivityManager manager) {
this.manager = manager;
}
- // ====================================
- // What makes an open window "the activity menu". Public because the
- // manager asks the same question a tick after a click, to decide whether
- // the window it is about to close is still ours - identity of the
- // InventoryView is not something Bukkit promises.
- // ====================================
public static final class Marker implements InventoryHolder {
private Inventory inventory;
@@ -102,12 +82,6 @@ public Inventory build(Player player) {
return inventory;
}
- // ====================================
- // One task slot: the hidden dye until it is revealed, then the activity's
- // own item. null - which fillEmptySlots then turns into filler - covers
- // both a slot beyond what could be drawn and one holding an activity a
- // reload has since removed.
- // ====================================
private ItemStack taskItem(ActivityConfiguration config, Messages messages, PlayerData data, int slot) {
String id = taskIdAt(config, data, slot);
if (id == null) {
@@ -120,12 +94,6 @@ private ItemStack taskItem(ActivityConfiguration config, Messages messages, Play
return activityItem(config, messages, def, data);
}
- // ====================================
- // Which activity a task slot shows, or null when it shows filler: a slot
- // beyond what could be drawn, and one holding an activity a reload has
- // since removed, both have nothing to paint. Package-private and pure so
- // the seven-slot paint decision can be tested without a live inventory.
- // ====================================
static String taskIdAt(ActivityConfiguration config, PlayerData data, int slot) {
List tasks = data.tasks();
if (slot < 0 || slot >= tasks.size()) {
@@ -135,11 +103,6 @@ static String taskIdAt(ActivityConfiguration config, PlayerData data, int slot)
return config.activity(id) == null ? null : id;
}
- // ====================================
- // Marketblock's filler: every slot the loops above left null gets a gray
- // pane instead, so the window reads as intentionally designed rather than
- // half-empty. Clicks on it are already cancelled by onClick.
- // ====================================
private void fillEmptySlots(Inventory inventory, Messages messages) {
ItemStack filler = filler(messages);
for (int slot = 0; slot < SIZE; slot++) {
@@ -149,10 +112,6 @@ private void fillEmptySlots(Inventory inventory, Messages messages) {
}
}
- // ====================================
- // The weekly bar, which is also the claim button: the marked-up bar, where
- // the rewards sit, and then what the player can do about it right now.
- // ====================================
private ItemStack barItem(ActivityConfiguration config, Messages messages, PlayerData data) {
List milestones = config.milestones();
String bar = Bar.render(data.points(), config.barMax(), config.barLength(), milestones);
@@ -175,8 +134,6 @@ private ItemStack barItem(ActivityConfiguration config, Messages messages, Playe
lore);
}
- // The lowest milestone still ahead of what has been paid for, or null once
- // every one of them has been claimed this week
private static Integer nextMilestone(PlayerData data, List milestones) {
for (Integer milestone : milestones) {
if (milestone > data.claimedPoints()) {
@@ -186,11 +143,6 @@ private static Integer nextMilestone(PlayerData data, List milestones)
return null;
}
- // ====================================
- // The reroll button. Everyone sees it; the lore is what differs, so a
- // player without the rank learns the perk exists instead of wondering
- // what the button is for.
- // ====================================
private ItemStack rerollItem(ActivityConfiguration config, Messages messages, PlayerData data, Player player) {
int perDay = config.rerollsPerDay();
String lore;
@@ -199,8 +151,6 @@ private ItemStack rerollItem(ActivityConfiguration config, Messages messages, Pl
} else if (!player.hasPermission("activity.reroll")) {
lore = messages.get("gui.reroll-lore-locked");
} else if (data.dailyPoints() > config.rerollMaxPoints()) {
- // Past the threshold the button refuses for the rest of the day,
- // so showing a remaining count would only be a lie
lore = messages.get("gui.reroll-lore-too-late", "%points%", config.rerollMaxPoints());
} else {
lore = messages.get("gui.reroll-lore-left",
@@ -224,16 +174,6 @@ private ItemStack activityItem(ActivityConfiguration config, Messages messages,
activityLore(messages, def, data.count(def.id())));
}
- // ====================================
- // A revealed task's lore: the operator's description first, then the
- // progress bar, then what the day has paid so far. The description is
- // free-form operator text with no label or placeholder of its own, so it
- // is emitted as configured rather than through a messages.yml key - the
- // same treatment def.display() gets, colour codes and all. Only reached
- // from activityItem, never from the hidden-task branch, so a description
- // cannot give away an unrevealed task. Package-private and pure so the
- // ordering can be pinned without a live Bukkit inventory.
- // ====================================
static List activityLore(Messages messages, ActivityDef def, int count) {
List lore = new ArrayList<>();
for (String line : def.description()) {
@@ -249,16 +189,6 @@ static List activityLore(Messages messages, ActivityDef def, int count)
return lore;
}
- // ====================================
- // Progress toward the NEXT point, not the whole day: once the daily cap is
- // reached the bar is shown full rather than reset to empty. Below the cap
- // it is count % every out of every, so it fills and restarts once per
- // point earned. An uncapped 'every: 1' has no meaningful partial progress
- // (every count is already a whole point), so no bar is shown; capped at
- // every: 1 still shows full, same as any other capped activity.
- // Package-private and static so a test can check it without a live
- // Bukkit inventory.
- // ====================================
static String progressBar(ActivityDef def, int count) {
if (def.dailyCap() > 0 && def.worth(count) >= def.capPoints()) {
return Bar.render(1, 1, PROGRESS_BAR_LENGTH);
@@ -269,13 +199,6 @@ static String progressBar(ActivityDef def, int count) {
return null;
}
- // ====================================
- // An icon written as a TLibs item path is built by TLibs, so an MMOItems
- // icon keeps its model and texture; an ia. one is built by
- // ItemsAdder the same way. The name and lore below are then set on it like
- // on any other icon. The backing plugin missing, or the path no longer
- // resolving, falls back to the Material - which is PAPER behind any path.
- // ====================================
private ItemStack iconStack(ActivityConfiguration config, Material icon, String iconPath) {
ItemStack fromPath = fromPath(config, iconPath);
if (fromPath != null && !fromPath.getType().isAir()) {
@@ -285,21 +208,10 @@ private ItemStack iconStack(ActivityConfiguration config, Material icon, String
}
private ItemStack fromPath(ActivityConfiguration config, String iconPath) {
- // Lambda, not a TLibsItems::item method reference: a reference would
- // resolve the TLibsItems class eagerly, ahead of the itemPathsUsable()
- // gate, and reintroduce the class-init crash on a server without the
- // TLibs trio (see ActivityManager's resolveRewardItem for the same rule).
return fromPath(iconPath, config.itemPathsUsable(), config.itemsAdderUsable(),
path -> TLibsItems.item(path), id -> ItemsAdderItems.item(id));
}
- // Each path form on its own gate: a server without ItemsAdder must still
- // build its m. icons, and one without the TLibs trio its ia. ones. Neither
- // hook throws - both answer null for anything they cannot build.
- //
- // Static and with both hooks handed in, so all four gate combinations can
- // be pinned headless - the two gates being independent is the whole point
- // of the feature, and an '&&' slipped in here would otherwise go unnoticed.
static ItemStack fromPath(String iconPath, boolean tlibs, boolean ia,
Function tlibsItems,
Function iaItems) {
@@ -317,20 +229,6 @@ private ItemStack item(Material material, String name, List lore) {
return item(new ItemStack(material), name, lore);
}
- // ====================================
- // Every icon this GUI shows passes through here, so the enchant glint
- // (kept) never drags an "Efficiency II" tooltip line along with it - nor,
- // for an enchanted book or a custom item carrying stored enchantments
- // rather than applied ones, the same line under its "Stored Enchantments"
- // heading - and a custom item's "When in Main Hand: ..." attribute lines
- // stay off too. HIDE_ATTRIBUTES alone is not enough on this API version: a
- // vanilla tool with no explicit attribute modifiers still shows its
- // built-in attack-damage lines, because those come from the item's
- // default component rather than an explicit modifier list a flag can
- // hide. Setting an empty modifier map overrides that default with "no
- // modifiers at all", which starves HIDE_ATTRIBUTES of anything to hide in
- // the first place.
- // ====================================
private ItemStack item(ItemStack stack, String name, List lore) {
ItemMeta meta = stack.getItemMeta();
if (meta != null) {
@@ -343,11 +241,6 @@ private ItemStack item(ItemStack stack, String name, List lore) {
return stack;
}
- // ====================================
- // Nothing in the view is takeable, so every click in it is cancelled -
- // including shift-clicks from the player's own inventory, which is why the
- // top inventory is what gets checked rather than the clicked slot.
- // ====================================
@EventHandler
public void onClick(InventoryClickEvent event) {
if (!(event.getView().getTopInventory().getHolder() instanceof Marker)) {
@@ -355,10 +248,6 @@ public void onClick(InventoryClickEvent event) {
}
event.setCancelled(true);
- // Bottom-inventory clicks (the player's own inventory) and clicks
- // outside any inventory (raw slot -999) are cancelled above but must
- // not reach the controls below, which only make sense for a slot in
- // our own top inventory
if (event.getClickedInventory() != event.getView().getTopInventory()) {
return;
}
@@ -367,9 +256,6 @@ public void onClick(InventoryClickEvent event) {
return;
}
- // The permission is checked on /activity, but an inventory can outlive
- // the permission that opened it - a revoked player must not still be
- // able to claim a reward or reveal a task from a window they left open
if (!player.hasPermission("activity.use")) {
return;
}
@@ -396,50 +282,22 @@ public void onClick(InventoryClickEvent event) {
clickSound(player);
player.playSound(player.getLocation(), Sound.ENTITY_PLAYER_LEVELUP, 1f, 1f);
} else if (!reveal.drawChanged()) {
- // ====================================
- // Nothing to reveal and nothing to repaint: the task was already
- // revealed. That is the click that runs the activity's
- // 'click-commands', if it configures any - so the first click
- // reveals and every later one runs them. The manager owns the
- // revealed check, the dispatch, the two rate limits and the
- // close; a click it refused (nothing to run, or one of the
- // limits) stays as silent as it has always been, and only a click
- // that really dispatched something makes a sound.
- // ====================================
if (manager.runClickCommands(player, task)) {
clickSound(player);
}
return;
}
- // ====================================
- // Slots on the open view, never a reopen: reopening the inventory from
- // inside a click event is discouraged by Bukkit and would drop
- // whatever is on the player's cursor. Just the clicked slot normally -
- // but a draw that was compacted or topped up mid-click (a reload
- // dropped an activity, the day rolled over, /activity reset ran) has
- // moved every task after the change, so then all seven are repainted.
- // ====================================
ActivityConfiguration config = manager.getConfiguration();
PlayerData data = manager.tasks(player.getUniqueId());
Inventory top = event.getView().getTopInventory();
if (reveal.drawChanged()) {
- // The draw only moves at a rollover or a reload, both of which
- // wipe or rescale what the two bars show - repainting the tasks
- // alone would leave a fresh draw beside a stale "10/10" daily bar
- // and a claim button that no longer does anything
repaintAll(top, config, data, player);
} else {
top.setItem(event.getRawSlot(), taskOrFiller(config, data, task));
}
}
- // ====================================
- // The reveal half of a task click, apart from the event and the sounds
- // (Sound needs a running server) so it can be driven headless: reveals
- // the slot and, when that revealed something, pays the daily reward if
- // this was the last of today's draw.
- // ====================================
ActivityManager.Reveal revealTask(Player player, int task) {
ActivityManager.Reveal reveal = manager.reveal(player.getUniqueId(), task);
if (reveal.revealedId() != null) {
@@ -448,8 +306,6 @@ ActivityManager.Reveal revealTask(Player player, int task) {
return reveal;
}
- // Every slot build() paints, on the open view: used wherever a click
- // changed the draw and the bars at once
private void repaintAll(Inventory top, ActivityConfiguration config, PlayerData data, Player player) {
Messages messages = config.messages();
top.setItem(DAILY_BAR_SLOT, dailyBarItem(config, messages, data));
@@ -460,14 +316,6 @@ private void repaintAll(Inventory top, ActivityConfiguration config, PlayerData
}
}
- // ====================================
- // The reroll click. The permission is checked here rather than in the
- // manager: a player without it is not a failed reroll but a sales pitch,
- // and nothing about their data is read or written.
- //
- // A feature the server has switched off is not a sales pitch either, so
- // that is checked first - the same order rerollItem() paints the lore in.
- // ====================================
private void reroll(Player player, InventoryClickEvent event) {
ActivityConfiguration config = manager.getConfiguration();
Messages messages = config.messages();
@@ -483,9 +331,6 @@ private void reroll(Player player, InventoryClickEvent event) {
}
switch (manager.reroll(player.getUniqueId())) {
- // Unreachable: the rerollsPerDay() <= 0 guard above already
- // returns on this exact condition. Kept so the switch stays
- // exhaustive over Rerolled without a default branch.
case DISABLED -> player.sendMessage(messages.get("reroll-disabled"));
case FAILED -> player.sendMessage(messages.get("reroll-failed"));
case NONE_LEFT -> player.sendMessage(messages.get("reroll-none-left"));
@@ -499,8 +344,6 @@ private void reroll(Player player, InventoryClickEvent event) {
}
}
- // A repaint must leave nothing stale behind, so a slot with no task in it
- // gets the same filler build() would have put there
private ItemStack taskOrFiller(ActivityConfiguration config, PlayerData data, int slot) {
Messages messages = config.messages();
ItemStack task = taskItem(config, messages, data, slot);
@@ -511,8 +354,6 @@ private ItemStack filler(Messages messages) {
return item(Material.GRAY_STAINED_GLASS_PANE, messages.get("gui.filler-name"), List.of());
}
- // The task index this raw slot holds, or -1 for any other slot.
- // Package-private so the click routing can be tested without a server.
static int taskSlot(int rawSlot) {
for (int slot = 0; slot < TASK_SLOTS.length; slot++) {
if (TASK_SLOTS[slot] == rawSlot) {
@@ -522,28 +363,17 @@ static int taskSlot(int rawSlot) {
return -1;
}
- // ====================================
- // The per-player click-commands cooldown would otherwise keep an entry
- // for every player who ever clicked a task, for the whole uptime. This
- // listener is registered unconditionally (see ActivityPlugin), so there
- // is no new registration to add for it.
- // ====================================
@EventHandler
public void onQuit(PlayerQuitEvent event) {
manager.forgetClickCooldown(event.getPlayer().getUniqueId());
}
- // Marketblock's menu click; a successful claim still plays its own sound
private static void clickSound(Player player) {
player.playSound(player.getLocation(), Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f);
}
private void claim(Player player, InventoryClickEvent event) {
ActivityConfiguration config = manager.getConfiguration();
- // A claim of 0 is either "nothing was due" or a refusal, and claim()
- // has already told the player about every refusal. Nothing was due
- // needs no line either - the bar's own lore says when the next reward
- // lands.
if (manager.claim(player) == 0) {
return;
}
@@ -551,11 +381,6 @@ private void claim(Player player, InventoryClickEvent event) {
barItem(config, config.messages(), manager.getStore().get(player.getUniqueId())));
}
- // ====================================
- // A drag is not a click, so it slips past onClick entirely. Raw slots
- // below SIZE are the top inventory - a drag that only touches the
- // player's own inventory is left alone.
- // ====================================
@EventHandler
public void onDrag(InventoryDragEvent event) {
if (!(event.getView().getTopInventory().getHolder() instanceof Marker)) {
diff --git a/src/main/java/tfmc/justin/activity/hooks/ItemsAdderItems.java b/src/main/java/tfmc/justin/activity/hooks/ItemsAdderItems.java
index f8da13a..286583a 100644
--- a/src/main/java/tfmc/justin/activity/hooks/ItemsAdderItems.java
+++ b/src/main/java/tfmc/justin/activity/hooks/ItemsAdderItems.java
@@ -11,51 +11,13 @@
import java.util.function.Function;
import java.util.logging.Logger;
-// ====================================
-// The only class that touches ItemsAdder, and it does so purely by
-// reflection: ItemsAdder is not a compile-time dependency of this plugin and
-// must not become one, so a server without it simply never resolves an
-// ia. path.
-//
-// Shaped like TLibsItems: the call itself is handed in, so the logic around
-// it (a failing id is reported once, a null or AIR stack reads as "no item")
-// can be exercised headless, and fromItemsAdder() - the only method that
-// names an ItemsAdder class - only runs when the default instance is first
-// used.
-//
-// Every failure mode of the ItemsAdder call ends the same way, with null:
-// nothing ItemsAdder throws - from the call itself or from inspecting what it
-// handed back - may escape into a GUI build or into a reward payout, where it
-// would cost the whole menu or the whole claim rather than one icon.
-// ====================================
public final class ItemsAdderItems {
private final Function creator;
private final Logger logger;
- // ====================================
- // Ids whose call threw a genuinely permanent failure: ItemsAdder absent at
- // class-load time (ClassNotFoundException/LinkageError), or a reflective
- // signature that does not exist (NoSuchMethodException,
- // IllegalAccessException). The creator - and ItemsAdder's own logging with
- // it - is not invoked for them again.
- //
- // Deliberately NOT where a "getInstance returned null" id lands, nor an
- // InvocationTargetException (the reflected call itself threw, inside
- // ItemsAdder's own code): both are ItemsAdder's answer for the whole
- // duration of /iareload and of its async pack load, and memoising either
- // here would turn one GUI open inside that window into a permanently
- // broken icon and a reward that can never be paid again. Those ids only go
- // into reportedIds below, which silences the log line and nothing else.
- // ====================================
private final Set brokenIds = ConcurrentHashMap.newKeySet();
- // Ids ItemsAdder did not know about, so a broken config.yml entry warns
- // once rather than once per GUI open or per claim. Resolution is still
- // retried every time - same idiom as ActivityManager.reportedItemPaths,
- // which gates the warning and not the work. Cleared on the id's next
- // successful resolve too, not just by clearMemos() - so the warning is
- // available again if it later starts failing for a different reason.
private final Set reportedIds = ConcurrentHashMap.newKeySet();
ItemsAdderItems(Function creator, Logger logger) {
@@ -68,15 +30,10 @@ private static final class Default {
new ItemsAdderItems(ItemsAdderItems::fromItemsAdder, Bukkit.getLogger());
}
- // The item behind a ':', or null if ItemsAdder cannot build
- // one. Callers must have checked that ItemsAdder is enabled first.
public static ItemStack item(String id) {
return Default.INSTANCE.resolve(id);
}
- // Forgets both memos. Called from the /activity reload path, so an
- // operator who reinstalled or repacked an item has a way to clear a
- // permanently-broken id without restarting the server.
public static void reset() {
Default.INSTANCE.clearMemos();
}
@@ -92,23 +49,11 @@ ItemStack resolve(String id) {
}
try {
ItemStack stack = creator.apply(id);
- // Compared against AIR rather than through Material#isAir(), which
- // goes through the block registry and so needs a running server.
- // Inside the try, so a stack whose getType() throws costs this icon
- // and not the build around it
if (stack == null || stack.getType() == null || stack.getType() == Material.AIR) {
warnOnce(reportedIds, id, "could not be resolved - check the namespace and id");
return null;
}
- // Restores the warning for free: the id resolving again (pack
- // reload finished, /iareload completed) means a later failure -
- // the id removed from the pack, say - is worth warning about
- // again rather than staying silenced by an old memo.
reportedIds.remove(id);
- // Cloned before anything is done to it: CustomStack#getItemStack()
- // hands back ItemsAdder's own instance, and callers set a display
- // name and lore on what they get. Mutating it would rename the
- // pack's item for every later /iagive and every recipe result
ItemStack copy = stack.clone();
copy.setAmount(1);
return copy;
@@ -126,19 +71,6 @@ private void warnOnce(Set memo, String id, String why) {
}
}
- // CustomStack.getInstance("tfmc:saucepan").getItemStack(), by reflection:
- // nothing here is on the compile or runtime classpath.
- //
- // InvocationTargetException means the reflected call itself threw -
- // that is ItemsAdder's own code (getInstance/getItemStack), and it can
- // throw for the same transient reason getInstance() returns null: a
- // half-rebuilt registry during /iareload or the async pack load. Treated
- // as null so it lands in reportedIds (retried every call), not brokenIds.
- //
- // ClassNotFoundException, NoSuchMethodException and IllegalAccessException
- // mean the reflective signature this class relies on does not exist at
- // all - genuinely permanent for the uptime - so those are rethrown for
- // resolve() to file under brokenIds.
private static ItemStack fromItemsAdder(String id) {
try {
Class> customStack = Class.forName("dev.lone.itemsadder.api.CustomStack");
diff --git a/src/main/java/tfmc/justin/activity/hooks/PlaceholderHook.java b/src/main/java/tfmc/justin/activity/hooks/PlaceholderHook.java
index 8eaab7c..f70a9e8 100644
--- a/src/main/java/tfmc/justin/activity/hooks/PlaceholderHook.java
+++ b/src/main/java/tfmc/justin/activity/hooks/PlaceholderHook.java
@@ -11,12 +11,6 @@
import java.util.Locale;
-// ====================================
-// %activity_points%, %activity_max%, %activity_percent%, %activity_bar%,
-// %activity_claimable%, %activity_daily_points%, %activity_daily_max%,
-// %activity_done_%
-// Only registered when PlaceholderAPI is enabled - see ActivityPlugin.
-// ====================================
public class PlaceholderHook extends PlaceholderExpansion {
private final ActivityManager manager;
@@ -42,17 +36,9 @@ public String getVersion() {
@Override
public boolean persist() {
- // Survives a PlaceholderAPI reload, since the expansion is not a file
return true;
}
- // ====================================
- // A read path, and PlaceholderAPI is happy to ask about any player from
- // any thread - so peek() rather than get(): no entry is created for a name
- // that merely appeared in a scoreboard, and nothing is rolled off-tick.
- // The rollover is applied to the answer instead: a stored week that is not
- // the current one reads as an empty bar, a stale day as nothing done yet.
- // ====================================
@Override
public String onRequest(OfflinePlayer player, String params) {
if (player == null) {
@@ -62,21 +48,10 @@ public String onRequest(OfflinePlayer player, String params) {
ActivityConfiguration config = manager.getConfiguration();
PlayerData data = manager.getStore().peek(player.getUniqueId());
- // One clock reading for both keys: asked separately they can straddle
- // a midnight tick and disagree about which day this week it is
ActivityConfiguration.Keys keys = config.currentKeys();
boolean currentWeek = data != null && data.weekKey().equals(keys.week());
- // Clamped here too: peek() never runs the clamp get() does, so a lowered
- // bar.max would otherwise read over 100% until the next action
int points = currentWeek ? Math.min(data.points(), config.barMax()) : 0;
- // ====================================
- // Only the fixed keywords and the "done_" prefix are matched
- // case-insensitively, via this lowercased copy. The activity id
- // itself is cut out of the original 'params' below, preserving its
- // case, since config keys are case-sensitive everywhere else in this
- // plugin - %activity_done_Vote% must not resolve as "vote".
- // ====================================
String key = params.toLowerCase(Locale.ROOT);
switch (key) {
@@ -89,7 +64,6 @@ public String onRequest(OfflinePlayer player, String params) {
case "claimable":
return String.valueOf(currentWeek ? data.claimable(config.milestones()) : 0);
case "daily_points":
- // A stale day has nothing earned today, whatever is stored
return String.valueOf(currentWeek && data.dayKey().equals(keys.day())
? Math.min(data.dailyPoints(), config.dailyMax()) : 0);
case "daily_max":
@@ -106,8 +80,6 @@ public String onRequest(OfflinePlayer player, String params) {
if (def == null) {
return "";
}
- // "Done" is the daily cap reached, or - for an uncapped activity -
- // at least one award earned today
int today = currentWeek && data.dayKey().equals(keys.day())
? def.worth(data.count(def.id())) : 0;
boolean done = def.dailyCap() > 0 ? today >= def.capPoints() : today > 0;
diff --git a/src/main/java/tfmc/justin/activity/hooks/TLibsItems.java b/src/main/java/tfmc/justin/activity/hooks/TLibsItems.java
index 8e47471..09d08a1 100644
--- a/src/main/java/tfmc/justin/activity/hooks/TLibsItems.java
+++ b/src/main/java/tfmc/justin/activity/hooks/TLibsItems.java
@@ -15,21 +15,6 @@
import java.util.function.Predicate;
import java.util.logging.Logger;
-// ====================================
-// The only class that touches TLibs. Like the listeners in
-// ActivityPlugin#registerHooks it is never loaded on a server without the
-// plugin, because every caller checks isPluginEnabled("TLibs") first.
-//
-// Both calls are wrapped: a path naming an MMOItems item that no longer
-// exists is an admin typo in config.yml, and it must cost one icon or one
-// craft credit, not the whole GUI or the craft event.
-//
-// The two TLibs calls are handed in rather than called inline, so the logic
-// around them (first match wins, a throwing path is skipped, a failing path
-// is logged once) can be exercised headless. fromTLibs() is the only place
-// that names a TLibs class, and it only runs when the default instance is
-// first used - so this class loads and tests fine without TLibs.
-// ====================================
public final class TLibsItems {
private final Function creator;
@@ -37,15 +22,8 @@ public final class TLibsItems {
private final Predicate unresolved;
private final Logger logger;
- // Paths already logged as failing, so a broken config.yml entry warns
- // once rather than once per GUI open or per craft.
private final Set reportedPaths = ConcurrentHashMap.newKeySet();
- // Paths already known unresolved or throwing in resolve(), so the creator
- // (and TLibs' own per-call logging) is not invoked again for them. Kept
- // separate from reportedPaths: that set is also written by firstMatch()
- // for a throwing checker, which says nothing about whether the creator
- // side resolves the same path.
private final Set unresolvedPaths = ConcurrentHashMap.newKeySet();
TLibsItems(Function creator, BiPredicate checker,
@@ -56,8 +34,6 @@ public final class TLibsItems {
this.logger = logger;
}
- // Built on first use, not on class load: touching TLibs any earlier would
- // defeat the "never loaded without the plugin" rule above.
private static final class Default {
static final TLibsItems INSTANCE = fromTLibs();
}
@@ -70,34 +46,19 @@ private static TLibsItems fromTLibs() {
Bukkit.getLogger());
}
- // TLibs answers a path it recognises the shape of but cannot resolve
- // (MMOItems/MythicLib missing, or the id deleted) with null, air, or its
- // DIRT sentinel - all three must read as "no icon", not as a dirt block.
- //
- // The air constants are compared by identity rather than through
- // Material#isAir(), which goes through the block registry and so needs a
- // running server; these three are the whole of it.
static boolean isUnresolved(Material type) {
return type == null || type == Material.AIR || type == Material.CAVE_AIR
|| type == Material.VOID_AIR || type == Material.DIRT;
}
- // The item behind an m.. path, or null if TLibs cannot build one
public static ItemStack item(String path) {
return Default.INSTANCE.resolve(path);
}
- // The first path in order that this item matches, mapped to its activity
- // id, or null when it matches none
public static String match(ItemStack item, List> paths) {
return Default.INSTANCE.firstMatch(item, paths);
}
- // A path that resolves to nothing is reported once and then keeps
- // returning null without calling the creator again, so the caller falls
- // back every time without logging again - TLibs itself already logs on
- // every call. A path that resolves fine is never cached: the GUI wants a
- // fresh stack each time.
T resolve(String path) {
if (unresolvedPaths.contains(path)) {
return null;
@@ -121,9 +82,6 @@ T resolve(String path) {
return result;
}
- // Each path is checked independently so one that throws (a deleted
- // MMOItems id, a malformed path) is skipped rather than aborting the whole
- // scan and losing every path after it.
String firstMatch(T item, List> paths) {
for (Map.Entry entry : paths) {
try {
diff --git a/src/main/java/tfmc/justin/activity/listeners/AdvancedCraftListener.java b/src/main/java/tfmc/justin/activity/listeners/AdvancedCraftListener.java
index 46d8a10..851f184 100644
--- a/src/main/java/tfmc/justin/activity/listeners/AdvancedCraftListener.java
+++ b/src/main/java/tfmc/justin/activity/listeners/AdvancedCraftListener.java
@@ -9,15 +9,6 @@
import java.util.UUID;
-// ====================================
-// AdvancedCrafting fires both events synchronously on the main thread, once
-// per completed craft - neither carries a count, so each is worth 1. Neither
-// is cancellable. Only constructed when AdvancedCrafting is enabled - see
-// ActivityPlugin.
-//
-// getPlayerUuid() rather than getPlayer(), like TFMCCore does: the UUID is
-// carried separately, and recordAction does not need a live Player.
-// ====================================
public class AdvancedCraftListener implements Listener {
private final ActivityManager manager;
diff --git a/src/main/java/tfmc/justin/activity/listeners/ArchaeologyListener.java b/src/main/java/tfmc/justin/activity/listeners/ArchaeologyListener.java
index 861ae8e..35a5245 100644
--- a/src/main/java/tfmc/justin/activity/listeners/ArchaeologyListener.java
+++ b/src/main/java/tfmc/justin/activity/listeners/ArchaeologyListener.java
@@ -6,10 +6,6 @@
import org.bukkit.event.Listener;
import tfmc.justin.activity.managers.ActivityManager;
-// ====================================
-// Archaeo fires FindRecoveredEvent synchronously on the main thread. Only
-// constructed when Archaeo is enabled - see ActivityPlugin.
-// ====================================
public class ArchaeologyListener implements Listener {
private final ActivityManager manager;
@@ -20,8 +16,6 @@ public ArchaeologyListener(ActivityManager manager) {
@EventHandler(priority = EventPriority.MONITOR)
public void onFindRecovered(FindRecoveredEvent event) {
- // The source plugin builds the event itself; a null player would
- // only be a bug there, but it must not take this listener down
if (event.getPlayer() == null) {
return;
}
diff --git a/src/main/java/tfmc/justin/activity/listeners/BattleListener.java b/src/main/java/tfmc/justin/activity/listeners/BattleListener.java
index fe837af..9bc9e95 100644
--- a/src/main/java/tfmc/justin/activity/listeners/BattleListener.java
+++ b/src/main/java/tfmc/justin/activity/listeners/BattleListener.java
@@ -9,18 +9,6 @@
import java.util.Set;
import java.util.UUID;
-// ====================================
-// SimpleFactions fires BattleEndedEvent once per battle, carrying every
-// participant rather than a single player, so each one is credited. Not
-// cancellable. Only constructed when SimpleFactions is enabled - see
-// ActivityPlugin.
-//
-// Every participant of every ended battle is credited, including battles that
-// ran out the timer with no winning side - taking part is the activity.
-//
-// recordAction takes a UUID and tolerates an offline player, so a participant
-// who logged off before the battle ended still gets credited.
-// ====================================
public class BattleListener implements Listener {
private final ActivityManager manager;
diff --git a/src/main/java/tfmc/justin/activity/listeners/CasinoWinListener.java b/src/main/java/tfmc/justin/activity/listeners/CasinoWinListener.java
index 5b1b4ef..54ad379 100644
--- a/src/main/java/tfmc/justin/activity/listeners/CasinoWinListener.java
+++ b/src/main/java/tfmc/justin/activity/listeners/CasinoWinListener.java
@@ -7,14 +7,6 @@
import org.bukkit.event.player.PlayerQuitEvent;
import tfmc.justin.activity.managers.ActivityManager;
-// ====================================
-// Games fires PlayerWonMoneyEvent synchronously on the main thread after a
-// card-table payout. Only constructed when Games is enabled - see
-// ActivityPlugin.
-//
-// Profit is fractional denar, so the leftover fraction is carried between
-// wins rather than rounded away.
-// ====================================
public class CasinoWinListener implements Listener {
private final ActivityManager manager;
@@ -26,20 +18,14 @@ public CasinoWinListener(ActivityManager manager) {
@EventHandler(priority = EventPriority.MONITOR)
public void onPlayerWonMoney(PlayerWonMoneyEvent event) {
- // The source plugin builds the event itself; a null player would
- // only be a bug there, but it must not take this listener down
if (event.getPlayer() == null) {
return;
}
- // A hand that did not net a profit is not a win
if (!(event.getProfit() > 0)) {
return;
}
- // A hidden or undrawn task must not bank a fraction either - and
- // whatever it banked while it was revealed is dropped here, so it
- // cannot pay out on a later day the task comes back
if (!manager.isTracked(event.getPlayer().getUniqueId(), "casino_win")) {
carry.forget(event.getPlayer().getUniqueId(), "casino_win");
return;
diff --git a/src/main/java/tfmc/justin/activity/listeners/CharacterChatListener.java b/src/main/java/tfmc/justin/activity/listeners/CharacterChatListener.java
index 7038bd0..a15b27c 100644
--- a/src/main/java/tfmc/justin/activity/listeners/CharacterChatListener.java
+++ b/src/main/java/tfmc/justin/activity/listeners/CharacterChatListener.java
@@ -14,24 +14,6 @@
import java.util.UUID;
import java.util.regex.Pattern;
-// ====================================
-// RPCharacters builds CharacterChatEvent with async=false, so Bukkit refuses
-// to deliver it off the primary thread. Only constructed when RPCharacters is
-// enabled - see ActivityPlugin.
-//
-// MONITOR + ignoreCancelled: a message another plugin swallowed was never said.
-//
-// Anti-farm: a message only counts when it is longer than MIN_LENGTH, its
-// normalized form (lowercase letters only) has at least MIN_NORMALIZED
-// characters and MIN_DISTINCT distinct letters, is not itself periodic
-// (repeats of a short chunk, e.g. "hahaha" or a doubled sentence), and is
-// less than SIMILARITY alike (Levenshtein) to each of the sender's last
-// HISTORY such messages. Messages failing any gate never enter the history,
-// so filler or self-repetition can't reset it. History is kept across quits
-// so relogging can't clear it; memory is bounded at HISTORY short strings
-// per player who has chatted since startup. Main thread only, so a plain
-// HashMap is enough.
-// ====================================
public class CharacterChatListener implements Listener {
static final int MIN_LENGTH = 15;
@@ -40,7 +22,6 @@ public class CharacterChatListener implements Listener {
static final int HISTORY = 30;
static final double SIMILARITY = 0.8;
- // &a / §a legacy codes and RRGGBB hex, as players type them
private static final Pattern COLOUR = Pattern.compile("[&§](#[0-9a-fA-F]{6}|[0-9a-fk-orxA-FK-ORX])");
private final ActivityManager manager;
@@ -52,8 +33,6 @@ public CharacterChatListener(ActivityManager manager) {
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onCharacterChat(CharacterChatEvent event) {
- // The source plugin builds the event itself; a null sender would
- // only be a bug there, but it must not take this listener down
if (event.getSender() == null || event.getMessage() == null) {
return;
}
@@ -64,8 +43,6 @@ public void onCharacterChat(CharacterChatEvent event) {
}
}
- // text is already stripped. Stored whether or not it counts, so a chain
- // of variations never pays
boolean process(UUID uuid, String text) {
String norm = normalize(text);
if (!passesGate(text, norm)) {
@@ -80,7 +57,6 @@ boolean process(UUID uuid, String text) {
return counts;
}
- // for tests
int historySize(UUID uuid) {
Deque recent = history.get(uuid);
return recent == null ? 0 : recent.size();
@@ -90,17 +66,11 @@ static String strip(String message) {
return COLOUR.matcher(message).replaceAll("").trim();
}
- // text is stripped, norm is normalize(text)
static boolean passesGate(String text, String norm) {
return text.length() > MIN_LENGTH && norm.length() >= MIN_NORMALIZED
&& norm.chars().distinct().count() >= MIN_DISTINCT && !isPeriodic(norm);
}
- // True when norm is built from a chunk repeated (possibly partially) to
- // cover more than half its length, e.g. "hahaha" or a sentence said
- // twice back to back. Uses the KMP prefix function: the string has
- // period p = n - pi[n-1], where pi[n-1] is the longest proper
- // prefix that is also a suffix.
static boolean isPeriodic(String s) {
int n = s.length();
if (n == 0) {
@@ -121,7 +91,6 @@ static boolean isPeriodic(String s) {
return period <= n / 2;
}
- // history holds normalize() of recent messages
static boolean isNovel(String norm, Collection history) {
for (String b : history) {
if (similar(norm, b)) {
@@ -136,15 +105,12 @@ static boolean similar(String a, String b) {
if (max == 0) {
return true;
}
- // distance >= length difference, so this is an upper bound; same
- // formula as below so the boundary can't drift on rounding
if (1.0 - (double) Math.abs(a.length() - b.length()) / max < SIMILARITY) {
return false;
}
return 1.0 - (double) distance(a, b) / max >= SIMILARITY;
}
- // Levenshtein edit distance, two rolling rows
static int distance(String a, String b) {
int[] prev = new int[b.length() + 1];
int[] cur = new int[b.length() + 1];
diff --git a/src/main/java/tfmc/justin/activity/listeners/CraftListener.java b/src/main/java/tfmc/justin/activity/listeners/CraftListener.java
index 7bdf360..0e8ec5e 100644
--- a/src/main/java/tfmc/justin/activity/listeners/CraftListener.java
+++ b/src/main/java/tfmc/justin/activity/listeners/CraftListener.java
@@ -10,29 +10,6 @@
import org.bukkit.inventory.ItemStack;
import tfmc.justin.activity.managers.ActivityManager;
-// ====================================
-// Feeds the activities that carry a 'craft:' key. Always registered, unlike
-// the other listeners: a crafted item is checked against the vanilla
-// Material map first, then against TLibs m.. paths on a miss.
-//
-// CraftItemEvent fires *before* the container transaction runs, so nothing has
-// been crafted yet. Rather than look at the world afterwards to find out what
-// happened - every version of that is a race an autoclicker wins - the credit
-// is computed here and now from the same two things that bound vanilla's own
-// transaction:
-//
-// - a normal click takes exactly one batch, always;
-// - a shift-click repeats `quickMoveStack` while the result slot keeps
-// refilling and the destination keeps accepting, so it is bounded by the
-// shortest ingredient stack and by the room in the player's 36 storage
-// slots (AbstractContainerMenu#doClick, QUICK_MOVE).
-//
-// Nothing is read after the event returns, so pulling ingredients back out of
-// the grid, closing the window, disconnecting, or picking up items of the same
-// type mid-tick cannot inflate anything.
-//
-// Points here buy console-command rewards, so every judgement call errs low.
-// ====================================
public class CraftListener implements Listener {
private final ActivityManager manager;
@@ -47,9 +24,6 @@ public void onCraftItem(CraftItemEvent event) {
return;
}
- // The stack sitting in the result slot, and only that - never
- // getRecipe().getResult(). For a shift-click this is one batch, not
- // the whole run.
ItemStack result = event.getCurrentItem();
if (!creditable(
@@ -76,25 +50,6 @@ public void onCraftItem(CraftItemEvent event) {
}
}
- // ====================================
- // Is this click worth anything at all? Three necessary conditions for a
- // craft, all read before vanilla's transaction runs:
- //
- // - it is a click on the result slot. CraftBukkit only builds a
- // CraftItemEvent for raw slot 0, but nothing in the API promises that,
- // and if it ever slipped getCurrentItem() would be an ingredient stack
- // and a shift-click emptying the grid would credit that material.
- // - the result slot is not empty. CraftItemEvent gates on
- // CraftingInventory#getRecipe(), which reads ResultContainer's
- // recipeUsed - a field that survives the grid ceasing to match, and in
- // the player's own 2x2 menu survives for the whole session. So a grid
- // that no longer matches still fires this event with an EMPTY result
- // slot, and neither the click whitelist nor InventoryAction rules it
- // out (CraftBukkit reports PLACE_ALL for a left-click with a full
- // cursor, MOVE_TO_OTHER_INVENTORY for a shift-click). Vanilla crafts
- // nothing in that state.
- // - the click is one that takes the stack out of the slot.
- // ====================================
static boolean creditable(int rawSlot, boolean resultPresent, ClickType click,
InventoryAction action, boolean destinationOccupied) {
return rawSlot == 0
@@ -102,29 +57,7 @@ static boolean creditable(int rawSlot, boolean resultPresent, ClickType click,
&& takesFromResult(click, action, destinationOccupied);
}
- // ====================================
- // Does this click actually take the result stack out of the slot?
- //
- // CraftBukkit fires CraftItemEvent for *any* click on slot 0 while the
- // grid matches a recipe, and its InventoryAction guess is not a verdict on
- // whether vanilla will craft anything - it never reports NOTHING for the
- // two clicks below, both of which craft zero items:
- //
- // - a double-click (ClickType.DOUBLE_CLICK, reported as
- // COLLECT_TO_CURSOR). Vanilla's PICKUP_ALL branch only runs when the
- // clicked slot is empty or refuses pickup, and a ResultSlot is neither;
- // canTakeItemForPickAll excludes the result slots from the sweep too.
- // - a number-key or offhand swap onto an *occupied* destination.
- // ResultSlot#mayPlace is always false, so vanilla's SWAP branch needs
- // the hotbar/offhand slot to be empty; CraftBukkit still reports
- // HOTBAR_MOVE_AND_READD.
- //
- // So this is a whitelist, not a blacklist: an unknown or future click type
- // pays nothing rather than paying for a craft that never happened.
- // ====================================
static boolean takesFromResult(ClickType click, InventoryAction action, boolean destinationOccupied) {
- // Vanilla's own verdict that this click is a no-op: e.g. a drop-click
- // with a full cursor
if (action == InventoryAction.NOTHING) {
return false;
}
@@ -135,13 +68,8 @@ static boolean takesFromResult(ClickType click, InventoryAction action, boolean
};
}
- // The live destination of a swap, read at MONITOR - before vanilla's
- // transaction runs. Only the two swap clicks have one.
private static boolean destinationOccupied(Player player, CraftItemEvent event) {
return switch (event.getClick()) {
- // getHotbarButton() is -1 when the click was not a number-key one,
- // and an out-of-range index would throw; read that as occupied,
- // which pays nothing.
case NUMBER_KEY -> {
int button = event.getHotbarButton();
yield button < 0 || button > 8 || !isEmpty(player.getInventory().getItem(button));
@@ -151,18 +79,6 @@ private static boolean destinationOccupied(Player player, CraftItemEvent event)
};
}
- // ====================================
- // The whole credit decision. Batches, times the batch size.
- //
- // A normal click is one batch and needs no bounding: vanilla has already
- // decided the transaction is possible (InventoryAction.NOTHING is the
- // case where it is not), and it never runs twice for one click.
- //
- // A shift-click repeats until either the ingredients or the room run out,
- // so the smaller of the two is the ceiling. Vanilla will happily run one
- // extra pass that only partially lands, which this rounds away - erring
- // low is the acceptable direction.
- // ====================================
static int creditedAmount(boolean shiftClick, int resultAmount, int smallestIngredient, int freeSpace) {
if (resultAmount <= 0) {
return 0;
@@ -174,12 +90,6 @@ static int creditedAmount(boolean shiftClick, int resultAmount, int smallestIngr
return batches * resultAmount;
}
- // ====================================
- // How many times the grid can still produce, ignoring the recipe: every
- // craft takes exactly one item out of every occupied slot (a recipe that
- // leaves a remainder swaps that slot's item instead, and then stops
- // matching, which the shortest-stack rule already caps at one).
- // ====================================
private static int smallestIngredient(ItemStack[] matrix) {
int smallest = 0;
for (ItemStack item : matrix) {
@@ -191,22 +101,6 @@ private static int smallestIngredient(ItemStack[] matrix) {
return smallest;
}
- // ====================================
- // Room for the result in the slots vanilla's quick-move actually targets.
- // Both crafting menus move the result into the player's 36 storage slots
- // and nothing else - CraftingMenu#quickMoveStack moves into 10..46 and
- // InventoryMenu#quickMoveStack into 9..45, which in each case is the main
- // inventory plus the hotbar. Armour and the offhand are deliberately not
- // counted; getStorageContents() is exactly those 36 slots.
- //
- // ponytail: counts room for the result only, while ResultSlot#onTake also
- // drops the recipe's remainder items into the same 36 slots, so a
- // stackable remainder can steal the last slot the final pass needed and
- // the real craft count fall one batch short of the credit. No vanilla
- // recipe reaches it (smallestIngredient always binds first). Upgrade path
- // if a datapack recipe ever does: subtract one batch when the recipe has
- // remainders.
- // ====================================
private static int freeSpaceFor(Player player, ItemStack result) {
int max = result.getMaxStackSize();
int room = 0;
diff --git a/src/main/java/tfmc/justin/activity/listeners/DishCookedListener.java b/src/main/java/tfmc/justin/activity/listeners/DishCookedListener.java
index b4c3f57..6f1ecc4 100644
--- a/src/main/java/tfmc/justin/activity/listeners/DishCookedListener.java
+++ b/src/main/java/tfmc/justin/activity/listeners/DishCookedListener.java
@@ -6,10 +6,6 @@
import org.bukkit.event.Listener;
import tfmc.justin.activity.managers.ActivityManager;
-// ====================================
-// Cooking fires DishCookedEvent synchronously on the main thread. Only
-// constructed when Cooking is enabled - see ActivityPlugin.
-// ====================================
public class DishCookedListener implements Listener {
private final ActivityManager manager;
@@ -20,8 +16,6 @@ public DishCookedListener(ActivityManager manager) {
@EventHandler(priority = EventPriority.MONITOR)
public void onDishCooked(DishCookedEvent event) {
- // The source plugin builds the event itself; a null player would
- // only be a bug there, but it must not take this listener down
if (event.getPlayer() == null) {
return;
}
diff --git a/src/main/java/tfmc/justin/activity/listeners/FractionCarry.java b/src/main/java/tfmc/justin/activity/listeners/FractionCarry.java
index d02f216..c4935a6 100644
--- a/src/main/java/tfmc/justin/activity/listeners/FractionCarry.java
+++ b/src/main/java/tfmc/justin/activity/listeners/FractionCarry.java
@@ -8,43 +8,14 @@
import java.util.Map;
import java.util.UUID;
-// ====================================
-// Leftover fraction of a point per player per activity, so a 5.5 xp gain or a
-// 0.5 denar sale is not repeatedly rounded down to nothing. Kept per activity
-// because each has its own goal and daily cap - a mining fraction must not be
-// credited to fishing.
-//
-// The leftover is a BigDecimal, not a double: cent-denominated values drift
-// when summed in binary (250 additions of 0.10 land just short of 25), so a
-// player selling cheap items would never reach the goal. BigDecimal.valueOf
-// takes the shortest decimal representation, so 0.10 goes in as exactly 0.10.
-//
-// A plain HashMap is safe: the events that feed this are not async, and Bukkit
-// refuses to deliver a non-async event off the primary thread, so the source
-// plugin would throw before ever reaching a listener.
-//
-// In-memory only - the worst a restart costs a player is under one point.
-// ====================================
class FractionCarry {
private static final BigDecimal MAX = BigDecimal.valueOf(Integer.MAX_VALUE);
private final Map> carry = new HashMap<>();
- // The week and day every banked fraction belongs to - see add()
private ActivityConfiguration.Keys keys;
- // ====================================
- // Adds this event's value to the player's leftover for the activity and
- // returns the whole points to record now (0 if there are none yet).
- //
- // Fractions never cross a rollover: the daily counters they feed are wiped
- // at one, and a player who stays online through it would otherwise carry
- // the old cycle's sub-point into the new one. Both keys are watched, not
- // just the day: the week flips at the configured reset hour, which wipes
- // the counters while the day key is unchanged. The first event after
- // either key moves drops every banked fraction, for every player.
- // ====================================
int add(UUID uuid, String activityId, double value, ActivityConfiguration.Keys currentKeys) {
if (!currentKeys.equals(keys)) {
carry.clear();
@@ -57,17 +28,10 @@ int add(UUID uuid, String activityId, double value, ActivityConfiguration.Keys c
return credit.amount();
}
- // Nothing to carry for a player who is gone
void forget(UUID uuid) {
carry.remove(uuid);
}
- // ====================================
- // Dropped whenever the daily-task gate refuses the activity, which is the
- // cheapest way to keep a fraction banked while the task was revealed from
- // paying out days later when it is drawn and revealed again - the gate is
- // asked on every event, so this runs on the first refused one.
- // ====================================
void forget(UUID uuid, String activityId) {
Map byActivity = carry.get(uuid);
if (byActivity != null && byActivity.remove(activityId) != null && byActivity.isEmpty()) {
@@ -75,7 +39,6 @@ void forget(UUID uuid, String activityId) {
}
}
- // Whole points to record now, and the fraction left over for next time
record Credit(int amount, BigDecimal exact) {
double carry() {
@@ -83,11 +46,6 @@ record Credit(int amount, BigDecimal exact) {
}
}
- // ====================================
- // Pure: (leftover fraction, this event's value) -> what to record. The
- // incoming value is sanitized first so a poisoned one cannot corrupt the
- // stored carry, which always stays in [0, 1).
- // ====================================
static Credit credit(BigDecimal carry, double value) {
BigDecimal total = carry.add(BigDecimal.valueOf(sanitize(value)));
BigDecimal whole = total.setScale(0, RoundingMode.FLOOR);
@@ -101,13 +59,6 @@ static Credit credit(double carry, double value) {
return credit(BigDecimal.valueOf(carry), value);
}
- // ====================================
- // These values are doubles other plugins can set: NaN, a negative (an XP
- // penalty) and infinity are all worth nothing. The comparison is written
- // this way round so NaN fails it. Huge values are capped rather than
- // overflowing recordAction's int - and the cap also keeps BigDecimal.valueOf
- // away from the NaN and infinity it refuses to convert.
- // ====================================
private static double sanitize(double value) {
if (!(value > 0)) {
return 0;
diff --git a/src/main/java/tfmc/justin/activity/listeners/FurnitureListener.java b/src/main/java/tfmc/justin/activity/listeners/FurnitureListener.java
index cc11d6e..d7bf385 100644
--- a/src/main/java/tfmc/justin/activity/listeners/FurnitureListener.java
+++ b/src/main/java/tfmc/justin/activity/listeners/FurnitureListener.java
@@ -6,13 +6,6 @@
import org.bukkit.event.Listener;
import tfmc.justin.activity.managers.ActivityManager;
-// ====================================
-// InteractibleFurniture fires FurniturePlaceEvent synchronously on the main
-// thread. Only constructed when InteractibleFurniture is enabled - see
-// ActivityPlugin.
-//
-// MONITOR + ignoreCancelled: nothing was placed if the event was cancelled.
-// ====================================
public class FurnitureListener implements Listener {
private final ActivityManager manager;
@@ -23,8 +16,6 @@ public FurnitureListener(ActivityManager manager) {
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onFurniturePlace(FurniturePlaceEvent event) {
- // The event carries its own hasPlayer() check, so furniture placed by
- // the plugin rather than by someone is skipped
if (!event.hasPlayer() || event.getPlayer() == null) {
return;
}
diff --git a/src/main/java/tfmc/justin/activity/listeners/GeigerListener.java b/src/main/java/tfmc/justin/activity/listeners/GeigerListener.java
index 516dd25..8f78db5 100644
--- a/src/main/java/tfmc/justin/activity/listeners/GeigerListener.java
+++ b/src/main/java/tfmc/justin/activity/listeners/GeigerListener.java
@@ -6,11 +6,6 @@
import tfmc.justin.activity.managers.ActivityManager;
import tfmc.justin.events.GeigerSourceCollectEvent;
-// ====================================
-// geiger_counter fires GeigerSourceCollectEvent synchronously on the main
-// thread. Only constructed when geiger_counter is enabled - see
-// ActivityPlugin.
-// ====================================
public class GeigerListener implements Listener {
private final ActivityManager manager;
@@ -21,8 +16,6 @@ public GeigerListener(ActivityManager manager) {
@EventHandler(priority = EventPriority.MONITOR)
public void onGeigerSourceCollect(GeigerSourceCollectEvent event) {
- // The source plugin builds the event itself; a null player would
- // only be a bug there, but it must not take this listener down
if (event.getPlayer() == null) {
return;
}
diff --git a/src/main/java/tfmc/justin/activity/listeners/InjuryListener.java b/src/main/java/tfmc/justin/activity/listeners/InjuryListener.java
index 3756628..c818e6d 100644
--- a/src/main/java/tfmc/justin/activity/listeners/InjuryListener.java
+++ b/src/main/java/tfmc/justin/activity/listeners/InjuryListener.java
@@ -6,11 +6,6 @@
import org.bukkit.event.Listener;
import tfmc.justin.activity.managers.ActivityManager;
-// ====================================
-// RPCharacters fires CharacterInjuredEvent synchronously on the main thread
-// once per successful injury. Only constructed when RPCharacters is enabled
-// - see ActivityPlugin. Credits the injured player, not the attacker.
-// ====================================
public class InjuryListener implements Listener {
private final ActivityManager manager;
@@ -21,8 +16,6 @@ public InjuryListener(ActivityManager manager) {
@EventHandler(priority = EventPriority.MONITOR)
public void onCharacterInjured(CharacterInjuredEvent event) {
- // The source plugin builds the event itself; a null target would
- // only be a bug there, but it must not take this listener down
if (event.getTarget() == null) {
return;
}
diff --git a/src/main/java/tfmc/justin/activity/listeners/InstrumentListener.java b/src/main/java/tfmc/justin/activity/listeners/InstrumentListener.java
index c815388..db2680d 100644
--- a/src/main/java/tfmc/justin/activity/listeners/InstrumentListener.java
+++ b/src/main/java/tfmc/justin/activity/listeners/InstrumentListener.java
@@ -6,11 +6,6 @@
import tfmc.justin.activity.managers.ActivityManager;
import tfmc.justin.events.InstrumentPlayEvent;
-// ====================================
-// MusicalInstruments fires InstrumentPlayEvent synchronously on the main
-// thread. Only constructed when MusicalInstruments is enabled - see
-// ActivityPlugin.
-// ====================================
public class InstrumentListener implements Listener {
private final ActivityManager manager;
@@ -21,8 +16,6 @@ public InstrumentListener(ActivityManager manager) {
@EventHandler(priority = EventPriority.MONITOR)
public void onInstrumentPlay(InstrumentPlayEvent event) {
- // The source plugin builds the event itself; a null player would
- // only be a bug there, but it must not take this listener down
if (event.getPlayer() == null) {
return;
}
diff --git a/src/main/java/tfmc/justin/activity/listeners/JoinListener.java b/src/main/java/tfmc/justin/activity/listeners/JoinListener.java
index e5f6b12..5895e99 100644
--- a/src/main/java/tfmc/justin/activity/listeners/JoinListener.java
+++ b/src/main/java/tfmc/justin/activity/listeners/JoinListener.java
@@ -5,10 +5,6 @@
import org.bukkit.event.player.PlayerJoinEvent;
import tfmc.justin.activity.managers.ActivityManager;
-// ====================================
-// Join is the rollover trigger for returning players, and a nudge that a
-// reward is waiting to be claimed
-// ====================================
public class JoinListener implements Listener {
private final ActivityManager manager;
diff --git a/src/main/java/tfmc/justin/activity/listeners/MarketSaleListener.java b/src/main/java/tfmc/justin/activity/listeners/MarketSaleListener.java
index a005ff0..5b45529 100644
--- a/src/main/java/tfmc/justin/activity/listeners/MarketSaleListener.java
+++ b/src/main/java/tfmc/justin/activity/listeners/MarketSaleListener.java
@@ -7,14 +7,6 @@
import org.bukkit.event.player.PlayerQuitEvent;
import tfmc.justin.activity.managers.ActivityManager;
-// ====================================
-// MarketBlock fires MarketSaleEvent synchronously on the main thread after a
-// sale is paid out. Only constructed when MarketBlock is enabled - see
-// ActivityPlugin.
-//
-// Prices are fractional and small (half a denar a sale is normal), so the
-// leftover fraction is carried between sales rather than rounded away.
-// ====================================
public class MarketSaleListener implements Listener {
private final ActivityManager manager;
@@ -26,18 +18,14 @@ public MarketSaleListener(ActivityManager manager) {
@EventHandler(priority = EventPriority.MONITOR)
public void onMarketSale(MarketSaleEvent event) {
- // The source plugin builds the event itself; a null player would
- // only be a bug there, but it must not take this listener down
if (event.getPlayer() == null) {
return;
}
- // A sale that paid nothing is not an earning
if (!(event.getPrice() > 0)) {
return;
}
- // A hidden or undrawn task must not bank a fraction either
if (!manager.isTracked(event.getPlayer().getUniqueId(), "market_sale")) {
carry.forget(event.getPlayer().getUniqueId(), "market_sale");
return;
diff --git a/src/main/java/tfmc/justin/activity/listeners/MmoItemsStationListener.java b/src/main/java/tfmc/justin/activity/listeners/MmoItemsStationListener.java
index fc24d48..1cb8fcb 100644
--- a/src/main/java/tfmc/justin/activity/listeners/MmoItemsStationListener.java
+++ b/src/main/java/tfmc/justin/activity/listeners/MmoItemsStationListener.java
@@ -10,33 +10,6 @@
import org.bukkit.event.Listener;
import tfmc.justin.activity.managers.ActivityManager;
-// ====================================
-// Feeds MMOItems crafting-station crafts into whichever activity declares
-// 'station: ' or 'station: /' in config.yml. Only
-// constructed when MMOItems is enabled - see ActivityPlugin.
-//
-// MONITOR + ignoreCancelled: the craft only counts once every other plugin
-// has had its say and it is actually going through.
-//
-// Only a finished craft counts, and that is decided by getInteraction(), not
-// by hasResult(). Verified with javap -c on CraftingRecipe#whenUsed,
-// UpgradingRecipe#whenUsed and EditableCraftingStationView$CraftingQueueItem
-// in MMOItems 6.10.1 - the only three places that fire this event:
-// INSTANT_RECIPE instant craft went through
-// CRAFTING_QUEUE a finished queued craft was claimed
-// INTERACT_WITH_RECIPE a non-instant recipe was only added to the queue
-// CANCEL_QUEUE a queued craft was cancelled
-// UPGRADE_RECIPE an upgrading recipe was applied
-// The first two are the completed craft; the rest are not. hasResult() is
-// exactly 'result != null', and both completed-craft paths pass the output
-// stack only when the recipe has the OUTPUT_ITEM option - with
-// 'output-item: false' they pass null, so hasResult() misses those crafts
-// entirely. Upgrade recipes never carry a result and are still not counted,
-// same as before.
-//
-// One event is one action regardless of the output stack size, like the
-// other one-per-action activities.
-// ====================================
public class MmoItemsStationListener implements Listener {
private final ActivityManager manager;
@@ -53,7 +26,6 @@ public void onUseCraftingStation(PlayerUseCraftingStationEvent event) {
}
CraftingStation station = event.getStation();
- // getRecipe() is null for the event forms carrying no recipe at all
Recipe recipe = event.getRecipe();
if (station == null || recipe == null) {
return;
diff --git a/src/main/java/tfmc/justin/activity/listeners/ProfessionUpgradeListener.java b/src/main/java/tfmc/justin/activity/listeners/ProfessionUpgradeListener.java
index da211fd..6703471 100644
--- a/src/main/java/tfmc/justin/activity/listeners/ProfessionUpgradeListener.java
+++ b/src/main/java/tfmc/justin/activity/listeners/ProfessionUpgradeListener.java
@@ -6,11 +6,6 @@
import org.bukkit.event.Listener;
import tfmc.justin.activity.managers.ActivityManager;
-// ====================================
-// RPCharacters fires ProfessionUpgradePurchasedEvent synchronously on the
-// main thread once per successful purchase. Only constructed when
-// RPCharacters is enabled - see ActivityPlugin.
-// ====================================
public class ProfessionUpgradeListener implements Listener {
private final ActivityManager manager;
@@ -21,8 +16,6 @@ public ProfessionUpgradeListener(ActivityManager manager) {
@EventHandler(priority = EventPriority.MONITOR)
public void onProfessionUpgradePurchased(ProfessionUpgradePurchasedEvent event) {
- // The source plugin builds the event itself; a null player would
- // only be a bug there, but it must not take this listener down
if (event.getPlayer() == null) {
return;
}
diff --git a/src/main/java/tfmc/justin/activity/listeners/ProfessionXpListener.java b/src/main/java/tfmc/justin/activity/listeners/ProfessionXpListener.java
index 58b14c5..8470711 100644
--- a/src/main/java/tfmc/justin/activity/listeners/ProfessionXpListener.java
+++ b/src/main/java/tfmc/justin/activity/listeners/ProfessionXpListener.java
@@ -11,17 +11,6 @@
import java.util.UUID;
-// ====================================
-// Feeds MMOCore profession XP into whichever activity declares
-// 'profession: ' in config.yml. Only constructed when MMOCore is enabled
-// - see ActivityPlugin.
-//
-// MONITOR + ignoreCancelled: the XP only counts once every other plugin has
-// had its say and the gain is actually going through.
-//
-// Fractional XP is carried between gains so a boosted 5.5 xp gain is not
-// repeatedly rounded down to 5 - see FractionCarry.
-// ====================================
public class ProfessionXpListener implements Listener {
private final ActivityManager manager;
@@ -33,8 +22,6 @@ public ProfessionXpListener(ActivityManager manager) {
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onExperienceGain(PlayerExperienceGainEvent event) {
- // Without a profession this is main-class/character XP, which no
- // activity tracks
if (!event.hasProfession()) {
return;
}
@@ -55,9 +42,6 @@ public void onExperienceGain(PlayerExperienceGainEvent event) {
}
UUID uuid = player.getUniqueId();
- // A hidden or undrawn task must not bank a fraction either - and
- // whatever it banked while it was revealed is dropped here, so it
- // cannot pay out on a later day the task comes back
if (!manager.isTracked(uuid, activityId)) {
carry.forget(uuid, activityId);
return;
diff --git a/src/main/java/tfmc/justin/activity/listeners/VehicleBuildListener.java b/src/main/java/tfmc/justin/activity/listeners/VehicleBuildListener.java
index d2b3330..5898947 100644
--- a/src/main/java/tfmc/justin/activity/listeners/VehicleBuildListener.java
+++ b/src/main/java/tfmc/justin/activity/listeners/VehicleBuildListener.java
@@ -8,11 +8,6 @@
import java.util.UUID;
-// ====================================
-// VFBuilders fires VehicleConstructEvent synchronously on the main thread.
-// Not cancellable - the vehicle already exists by the time it fires. Only
-// constructed when VFBuilders is enabled - see ActivityPlugin.
-// ====================================
public class VehicleBuildListener implements Listener {
private final ActivityManager manager;
@@ -23,9 +18,6 @@ public VehicleBuildListener(ActivityManager manager) {
@EventHandler(priority = EventPriority.MONITOR)
public void onVehicleConstruct(VehicleConstructEvent event) {
- // the uuid, not getConstructor(): that resolves to a live Player and is
- // null once the builder logs off, but recordAction takes a UUID and
- // credits an offline player just fine
UUID constructor = event.getConstructorUuid();
if (constructor == null) {
return;
diff --git a/src/main/java/tfmc/justin/activity/listeners/VoteListener.java b/src/main/java/tfmc/justin/activity/listeners/VoteListener.java
index fdf82a5..9fe592f 100644
--- a/src/main/java/tfmc/justin/activity/listeners/VoteListener.java
+++ b/src/main/java/tfmc/justin/activity/listeners/VoteListener.java
@@ -15,21 +15,9 @@
import java.util.UUID;
-// ====================================
-// VotingPlugin fires PlayerVoteEvent off the main thread for proxy-forwarded
-// votes, so the UUID is resolved here and the scoring hops back onto a tick.
-// Only constructed when VotingPlugin is enabled - see ActivityPlugin.
-//
-// MONITOR because the vote should only count once every other plugin has had
-// its say. PlayerVoteEvent has isCancelled() but does not implement
-// Cancellable, so ignoreCancelled does nothing - the check has to be manual.
-// ====================================
public class VoteListener implements Listener {
private final JavaPlugin plugin;
- // Held rather than looked up per vote, like the other listeners: the
- // static instance is nulled at shutdown, and this event can still be in
- // flight on the vote thread at that point
private final ActivityManager manager;
public VoteListener(JavaPlugin plugin, ActivityManager manager) {
@@ -43,25 +31,18 @@ public void onVote(PlayerVoteEvent event) {
return;
}
- // Test votes and reminders fire the same event
if (!event.isRealVote()) {
return;
}
- // Only plain data is read off the vote thread; every lookup that
- // touches server state happens on the tick below
VotingPluginUser user = event.getVotingPluginUser();
UUID javaUuid = user == null ? null : user.getJavaUUID();
String name = event.getPlayer();
- // The scheduler throws for a disabled plugin, and a vote arriving
- // during shutdown has nowhere to be scored anyway
if (!plugin.isEnabled()) {
return;
}
- // isEnabled() was true a line ago, but a disable landing in between
- // would throw on VotingPlugin's thread rather than ours
try {
Bukkit.getScheduler().runTask(plugin, () -> {
UUID uuid = resolve(javaUuid, name);
@@ -70,23 +51,14 @@ public void onVote(PlayerVoteEvent event) {
}
});
} catch (IllegalPluginAccessException | IllegalStateException e) {
- // Plugin is being disabled, vote dropped
}
}
- // ====================================
- // Main thread only: Bukkit.getPlayerExact and the offline-player cache are
- // not safe off a tick. Bukkit.getOfflinePlayer(String) is not used at all:
- // it blocks on a Mojang lookup and invents a UUID for names that have
- // never played, which would quietly fill players.yml with junk.
- // ====================================
private UUID resolve(UUID javaUuid, String name) {
if (javaUuid != null) {
return javaUuid;
}
- // Proxy-forwarded votes can arrive with no user attached; the name is
- // always set
if (name == null || name.isBlank()) {
plugin.getLogger().warning("Ignoring a vote with neither a user nor a player name.");
return null;
diff --git a/src/main/java/tfmc/justin/activity/managers/ActivityManager.java b/src/main/java/tfmc/justin/activity/managers/ActivityManager.java
index 1fb14e5..45fa64a 100644
--- a/src/main/java/tfmc/justin/activity/managers/ActivityManager.java
+++ b/src/main/java/tfmc/justin/activity/managers/ActivityManager.java
@@ -42,84 +42,38 @@
import java.util.stream.Collectors;
import java.util.regex.Pattern;
-// ====================================
-// The one place that turns "a player did something" into points, chat
-// feedback and rewards. Every intake path (events, command, API) lands here.
-// Main thread only.
-// ====================================
public class ActivityManager {
- // ====================================
- // Vanilla name charset. Floodgate/Bedrock names carry a prefix and can
- // contain spaces, which would split a console command into two - such a
- // name is never pasted into one.
- // ====================================
private static final Pattern SAFE_NAME = Pattern.compile("^[A-Za-z0-9_.]{1,16}$");
private static final long MINUTE_TICKS = 60L * 20L;
- // Nulled by shutdown() on the main thread while a vote can still be in
- // flight on VotingPlugin's own thread
private static volatile ActivityManager instance;
private final JavaPlugin plugin;
private final ActivityConfiguration config;
private final PlayerStore store;
- // A store that never loaded refuses every save for the rest of the
- // session, so saying so once per session is enough
private final Set warnedStoreNotLoaded = new HashSet<>();
- // Same idea for an empty reward pool, which a player can hit at click rate
- // - said once per load rather than once per click, and once per set of
- // pools, so a second broken pool is not swallowed by the first one's line
private final Set warnedEmptyPools = new HashSet<>();
- // The config.yml paths of a set of pool names, for a log line an operator
- // can go and edit: [pool, pool_skins] -> "rewards.pool, rewards.pool_skins"
private static String poolPaths(Set pools) {
return pools.stream().map(name -> name.equals(ActivityConfiguration.DEFAULT_POOL) ? "rewards.pool"
: "rewards.pools." + name + " (or rewards." + name + ")")
.collect(Collectors.joining(", "));
}
- // Reward item paths already reported as unresolvable, so a broken config
- // warns once rather than once per claim click. Static because giveItems is,
- // and cleared by reload() the way warnedEmptyPools is.
static final Set reportedItemPaths = ConcurrentHashMap.newKeySet();
- // ====================================
- // Same idea for a configured command the console refused or that threw:
- // an unknown command (the shipped 'sudo %player% votelist' on a server
- // with neither CMI nor EssentialsX, say) would otherwise write a line per
- // click per player for the whole uptime. Keyed by kind + the configured
- // command, so the same text under 'reward' and under 'click' is still
- // reported once each, and cleared by reload() the way the set above is.
- // ====================================
static final Set reportedBrokenCommands = ConcurrentHashMap.newKeySet();
- // uuid -> the nanoTime of that player's last click-commands dispatch.
- // Written on the main thread (InventoryClickEvent) and emptied per player
- // by the GUI's quit handler, but concurrent like every other shared map
- // here: both accessors are public and this class is already touched off
- // the main thread.
private final Map clickCooldowns = new ConcurrentHashMap<>();
- // ====================================
- // The server-wide ceiling the per-player cooldown cannot give: a token
- // bucket refilled at click-commands-per-second, holding at most one
- // second's worth. Twenty players each holding a mouse button are twenty
- // dispatches a second past the cooldown; a hundred players are a hundred,
- // all synchronous on the main thread. Main thread only - every reader is
- // inside InventoryClickEvent.
- // ====================================
private double clickTokens;
private long clickTokensAt;
private boolean clickTokensPrimed;
- // One tick a minute crediting online, non-idle players. Cancelled by
- // shutdown; reload leaves it running, since it reads the AFK threshold
- // fresh on every tick.
private BukkitTask playtime;
private ActivityManager(JavaPlugin plugin) {
@@ -143,12 +97,6 @@ public static boolean isSafeCommandName(String name) {
return name != null && SAFE_NAME.matcher(name).matches();
}
- // ====================================
- // Whether one configured console command can be run for one player. Only
- // a command that pastes %player% needs a name that survives being pasted
- // - a %uuid%-only command runs for a Bedrock name with a space in it just
- // fine. The same rule for a reward command and for a click command.
- // ====================================
public static boolean canRunCommand(String command, String name) {
return command != null && (!command.contains("%player%") || isSafeCommandName(name));
}
@@ -160,22 +108,7 @@ public void initialize() {
startPlaytime();
}
- // ====================================
- // Whole minutes only. A player who joins or leaves mid-minute loses the
- // part-minute rather than carrying it, which costs at most a minute a
- // session and saves keeping a per-player accumulator alive.
- //
- // recordAction is a no-op when 'playtime' is not a configured activity,
- // so the tick can run unconditionally - and starts working the moment an
- // admin adds the entry and reloads.
- //
- // The period is 1200 ticks, not 60 seconds: a server running behind
- // credits slower than the wall clock. Under-crediting is the safe
- // direction, so it is left alone.
- // ====================================
private void startPlaytime() {
- // A second initialize() would otherwise leak the first timer and pay
- // every online player twice a minute
if (playtime != null) {
playtime.cancel();
}
@@ -191,15 +124,6 @@ private void startPlaytime() {
}, MINUTE_TICKS, MINUTE_TICKS);
}
- // ====================================
- // null when the admin has switched the idle check off, which is the only
- // meaning 0 or a negative can carry - a zero-length threshold would
- // otherwise be met by every player on every tick and pay nobody.
- //
- // Worth knowing what this threshold is and is not: getIdleDuration()
- // resets on any packet from the client, so it only catches a client that
- // has stopped talking to the server at all.
- // ====================================
static Duration afkThreshold(int minutes) {
return minutes <= 0 ? null : Duration.ofMinutes(minutes);
}
@@ -209,10 +133,7 @@ public void shutdown() {
playtime.cancel();
playtime = null;
}
- // Cancels the autosave and writes the final snapshot under one lock
store.shutdown();
- // A disabled plugin must not still be reachable through the API, or a
- // late event would score into a store nobody will ever save again
instance = null;
}
@@ -224,33 +145,14 @@ public PlayerStore getStore() {
return store;
}
- // The plugin's logger, so the admin command can write its audit line
public Logger logger() {
return plugin.getLogger();
}
- // ====================================
- // Record progress towards an activity. What came of it is returned so the
- // admin command can say so - an unknown id, a refused gate and a cap that
- // swallowed the points all read as failures to an admin, and only this
- // method can tell them apart. Callers that cannot act on it (events, the
- // API) just ignore the result.
- //
- // The daily-task gate lives here: every listener, the playtime tick and
- // the API come through this method, and an activity that is not one of
- // the player's revealed tasks today records nothing at all.
- // ====================================
public Recorded recordAction(UUID uuid, String activityId, int amount) {
return record(uuid, activityId, amount, true).outcome();
}
- // ====================================
- // /activity add. Gated exactly like a listener unless the admin asked for
- // --force, which skips the daily-task gate AND credits the full worth of
- // the count past the activity's daily cap and today's budget (see
- // PlayerData.recordForced). The whole result comes back rather than just
- // the outcome, so the command can report how many points actually landed.
- // ====================================
public RecordResult recordAdmin(UUID uuid, String activityId, int amount, boolean force) {
return record(uuid, activityId, amount, !force);
}
@@ -261,20 +163,12 @@ private RecordResult record(UUID uuid, String activityId, int amount, boolean ga
return new RecordResult(0, 0, Recorded.UNKNOWN);
}
- // ====================================
- // The gated path never draws and never creates a row: a listener
- // firing for a player who has not opened the GUI today must not pin
- // that player in players.yml forever. No draw yet means no revealed
- // task, which means nothing to record.
- // ====================================
PlayerData data = gated ? store.rolled(uuid) : store.get(uuid);
if (gated && (data == null || !data.isRevealed(activityId))) {
return new RecordResult(0, 0, Recorded.NOT_A_TASK);
}
RecordResult result = gated
- // No vote activity loaded: nothing could fill the reserved share,
- // so there is no share to keep
? data.record(amount, def, config.barMax(), config.dailyMax(),
config.activity("vote") == null ? config.dailyMax() : config.nonVoteDailyMax(),
config.milestones())
@@ -282,14 +176,7 @@ private RecordResult record(UUID uuid, String activityId, int amount, boolean ga
return credited(uuid, data, result, Utils.colorize(def.display()));
}
- // ====================================
- // /activity addpoints: exactly this many points, with no activity and no
- // count behind them. Credited like a forced add (PlayerData.creditForced),
- // so bar.max clamps and neither daily limit applies, and announced by the
- // same tail as every other award.
- // ====================================
public RecordResult recordPoints(UUID uuid, int points) {
- // Before store.get, which would create and pin a row for nothing
if (points <= 0) {
return new RecordResult(0, 0, Recorded.UNKNOWN);
}
@@ -298,13 +185,9 @@ public RecordResult recordPoints(UUID uuid, int points) {
config.messages().get("points-granted-source"));
}
- // What every award does once the points are on the bar: save, and tell
- // an online player what landed and whether a reward is now waiting
private RecordResult credited(UUID uuid, PlayerData data, RecordResult result, String source) {
store.markDirty();
- // A full bar or a met daily cap awards nothing, and "+0" is worse
- // than silence
if (result.pointsAwarded() <= 0) {
return result;
}
@@ -329,24 +212,11 @@ private RecordResult credited(UUID uuid, PlayerData data, RecordResult result, S
return result;
}
- // ====================================
- // Whether this activity would earn anything for this player right now:
- // it is loaded and one of their revealed tasks today. Listeners that
- // carry fractions ask this first, so no fraction banks up for a task
- // that is hidden or was never drawn. Like the record path it neither
- // draws nor creates a row - a player with no draw yet is simply false.
- // ====================================
public boolean isTracked(UUID uuid, String activityId) {
PlayerData data = store.rolled(uuid);
return config.activity(activityId) != null && data != null && data.isRevealed(activityId);
}
- // ====================================
- // The player's data with today's draw made good: drawn if this is the
- // first time today it is needed, and topped back up if a reload removed
- // one of the activities in it. Only the GUI paths (open and reveal) come
- // here, so no listener event can create an entry.
- // ====================================
public PlayerData tasks(UUID uuid) {
PlayerData data = store.get(uuid);
if (ensureTasks(data)) {
@@ -363,44 +233,24 @@ private List activityIds() {
return config.activities().stream().map(ActivityDef::id).toList();
}
- // What a reroll click came to - the GUI says which of these it was, and
- // only DONE changed anything
public enum Rerolled {
DONE,
NONE_LEFT,
- // Too much already earned today (reroll.max-points) - checked before
- // NONE_LEFT, since the threshold blocks the rest of the day whatever
- // the budget says, and a "rerolls left: 1" that refuses is worse
TOO_LATE,
DISABLED,
- // players.yml was never loaded, so the spent reroll and the points it
- // took back could not be saved - refused rather than done in memory
FAILED
}
- // ====================================
- // Throws today's draw away and hands out a fresh one, taking today's
- // points back off the weekly bar (never below what has already been paid
- // out - see PlayerData.reroll). The draw is the same one ensureTasks
- // makes, so daily-guaranteed activities are still guaranteed afterwards.
- // Nothing is touched unless DONE is returned.
- // ====================================
public Rerolled reroll(UUID uuid) {
int perDay = config.rerollsPerDay();
if (perDay <= 0) {
return Rerolled.DISABLED;
}
- // Same rule as claim(): nothing that has to be persisted may happen
- // while nothing can be. A reroll done in memory only would cost the
- // player today's points until the next restart and hand the counter
- // back with them, making the per-day budget a per-restart one
if (storeNeverLoaded("Refusing every reroll")) {
return Rerolled.FAILED;
}
- // After get(), so a rollover has already reset dailyPoints: yesterday's
- // earnings must not block today's reroll
PlayerData data = store.get(uuid);
if (data.dailyPoints() > config.rerollMaxPoints()) {
return Rerolled.TOO_LATE;
@@ -416,13 +266,6 @@ public Rerolled reroll(UUID uuid) {
return Rerolled.DONE;
}
- // ====================================
- // True when players.yml was never read, in which case the store refuses
- // every save for the rest of the session and nothing that must survive a
- // restart may be done. Says so once per session per distinct 'refusing'
- // context, however many callers ask - reroll and reward-claim refusals
- // are warned about separately so one does not silence the other.
- // ====================================
private boolean storeNeverLoaded(String refusing) {
if (store.isLoaded()) {
return false;
@@ -434,27 +277,9 @@ private boolean storeNeverLoaded(String refusing) {
return true;
}
- // ====================================
- // What a reveal click did: the id actually revealed (null when the slot
- // was empty, already revealed, or holds an activity a reload has since
- // removed), and whether making the draw good moved the tasks around.
- // A caller drawing the draw has to repaint every slot when it did - the
- // slot the player clicked is no longer the one they saw.
- // ====================================
public record Reveal(String revealedId, boolean drawChanged) {
}
- // ====================================
- // Reveals the task in this slot (0-based).
- //
- // The id in the slot is read BEFORE the draw is made good, and it is that
- // id that gets revealed: making the draw good can compact and top up the
- // list, and applying the slot index afterwards would reveal whatever
- // slid into the slot rather than what the player clicked. An id the
- // top-up dropped (its activity is gone) reveals nothing. A slot that held
- // nothing to begin with - a player whose draw is being made for the first
- // time here - is still taken by index; there was nothing else to mean.
- // ====================================
public Reveal reveal(UUID uuid, int slot) {
PlayerData data = store.get(uuid);
List before = data.tasks();
@@ -473,44 +298,9 @@ public Reveal reveal(UUID uuid, int slot) {
return new Reveal(revealed ? data.tasks().get(index) : null, drawChanged);
}
- // ====================================
- // Hands over every milestone the player has reached but not yet claimed:
- // its fixed rewards.drops item, or else rewards.multiplier draws from the reward pool.
- // Only ever called for an online player (a GUI click), so %player% always
- // resolves. Returns how
- // many milestones were actually paid: 0 covers both "nothing was due" and
- // every refusal below, all of which leave the milestones there to claim
- // once an operator has fixed what broke.
- //
- // The order is the whole point, because a reward has real in-game value:
- //
- // 1. Every reason to refuse is checked before anything is touched. In
- // particular, the pool is first narrowed to the entries this player's
- // name can actually be paid from (see runnableEntries) - an unsafe
- // name that only some entries can pay is not a reason to fail the
- // whole claim, and it must not cost a disk write either way. That is
- // what a Bedrock player clicking the bar over and over hits.
- // 2. The milestones are burned and written to disk BEFORE the first
- // command runs: a crash between the give and the next autosave would
- // otherwise hand the whole lot out again on restart, and a reward
- // command that loops back in here must find them already paid.
- // 3. A save that failed pays nothing at all. The tick blocks on one file
- // write, which is the price of never paying twice.
- // 4. If the dispatch stops early - and it can, since a reward command
- // belongs to another plugin and is free to throw - only what actually
- // went out stays burned; the rest goes back, never below where it
- // started.
- //
- // The (already narrowed) pool is snapshotted once, so a reward command
- // that runs /activity reload cannot change what the rest of the loop
- // hands out.
- // ====================================
public int claim(Player player) {
Messages messages = config.messages();
- // Nothing may be paid while nothing can be persisted: a store that
- // never loaded refuses every write for the rest of the session, so a
- // payout here would last only until the next restart and then repeat
if (storeNeverLoaded("Refusing every reward claim")) {
player.sendMessage(messages.get("reward-failed"));
return 0;
@@ -523,14 +313,9 @@ public int claim(Player player) {
}
Map drops = config.milestoneDrops();
- // The pool refusals below only apply when a due milestone draws from
- // one - and then only to the pools those milestones actually name
Set needed = neededPools(due, drops);
boolean needsPool = !needed.isEmpty();
- // Nothing configured to hand over: burning the milestones here would
- // pay the player in silence. Load already warned about this, but a
- // claim is the moment an operator can tie the warning to a player.
Set empty = needed.stream().filter(name -> config.rewardPool(name).isEmpty())
.collect(Collectors.toCollection(LinkedHashSet::new));
if (!empty.isEmpty()) {
@@ -538,19 +323,10 @@ public int claim(Player player) {
plugin.getLogger().warning("Handed nothing to " + player.getUniqueId()
+ ": " + poolPaths(empty) + " has no usable entry, so the milestone stays claimable.");
}
- // The one refusal the player cannot read off the bar
player.sendMessage(messages.get("reward-unconfigured"));
return 0;
}
- // A name no entry in the pool can pay is a failure the player should
- // hear about, and it is answered here - before any mutation or save -
- // because it is the one refusal a player can trigger at click rate.
- // Entries that can pay only sometimes (a mix of %player% and %uuid%
- // commands) stay in; only the ones this name cannot pay at all drop out.
- // Narrowed once per pool and snapshotted, so a reward command that
- // runs /activity reload cannot change what the rest of the loop
- // hands out
Map> snapshot = new HashMap<>();
for (String name : needed) {
snapshot.put(name, runnableEntries(config.rewardPool(name), player.getName()));
@@ -579,7 +355,6 @@ public int claim(Player player) {
return 0;
}
- // Snapshotted with the pool (see payMilestones)
Payout payout = payMilestones(due, drops, config.rewardMultiplier(), pools, ActivityManager::draw,
(milestone, drawn, itemMultiplier) -> {
if (!dispatchRewards(player, drawn, "milestone " + milestone, itemMultiplier)) {
@@ -592,28 +367,10 @@ public int claim(Player player) {
int paid = payout.paid();
boolean spinFailed = payout.failed();
- // ====================================
- // Only what actually went out stays paid for. The second save is the
- // one that can leave memory and disk disagreeing, and the only honest
- // thing to do about it is to say exactly what each holds so an
- // operator can put the file right by hand.
- // ====================================
if (paid < due.size()) {
int rolledBack = rollbackClaimedPoints(claimedBefore, paid, due);
data.setClaimedPoints(rolledBack);
store.markDirty();
- // Nothing went out at all - the case a player holding the mouse
- // down on a broken pool hits on every click. The row is back where
- // this click found it, so the whole store is not serialised a
- // second time per click; only a partial payout, which changes what
- // was already written, is worth blocking the tick on.
- //
- // Disk still says claimedAfter until that queued write lands,
- // though, and a crash in between costs the player every milestone
- // he reached this week. So the write is queued now rather than
- // left to the next autosave tick (save-interval-minutes, 5 by
- // default), and the window is stated in the log so an operator who
- // finds the file that way knows what it should have held.
if (rolledBack == claimedBefore) {
store.saveSoon();
plugin.getLogger().warning("Reward payout for " + player.getUniqueId() + " paid none of the"
@@ -637,18 +394,6 @@ public int claim(Player player) {
return paid;
}
- // ====================================
- // The payout loop of claim(), in order over the due milestones: a fixed
- // drop is paid multiplier times its amount, a pool milestone is spun
- // multiplier times, each spin paid at multiplier 1. The spins are
- // independent, so one that fails does not stop the rest. A milestone
- // counts as paid once any of its spins went out - rolling it back would
- // spin it again on the next click and pay those twice - and the spins it
- // lost are logged every time, since only an operator can hand them over.
- // A milestone that paid nothing ends the loop and stays claimable, as does
- // every milestone after one that failed a spin. Takes the draw and the
- // payout so the loop can be pinned headless.
- // ====================================
record Payout(int paid, boolean failed) {}
@FunctionalInterface
@@ -685,17 +430,10 @@ static Payout payMilestones(List due, Map drops,
return new Payout(paid, false);
}
- // Does any of these milestones draw from a pool, rather than pay a
- // fixed rewards.drops item?
public static boolean needsPool(List milestones, Map drops) {
return !neededPools(milestones, drops).isEmpty();
}
- // ====================================
- // The pools these milestones draw from, by name: the default one for a
- // milestone with no fixed drop, and the named one for a drop written as
- // 'pool_'. Empty when every one of them pays a fixed item.
- // ====================================
public static Set neededPools(List milestones, Map drops) {
Set pools = new LinkedHashSet<>();
for (int milestone : milestones) {
@@ -707,18 +445,10 @@ public static Set neededPools(List milestones, Map rewardFor(int milestone, Map drops, int multiplier,
Function> pools,
Function
, RewardEntry> draw) {
@@ -735,46 +465,18 @@ static List rewardFor(int milestone, Map drop
return spins;
}
- // A fixed one-item entry with the amount it hands over put in front of its
- // bare-name display: "x3 Steel" at multiplier 1, "x6 Steel" at 2
static RewardEntry withPaidAmount(RewardEntry fixed, int multiplier) {
return new RewardEntry(fixed.weight(), "#50d990x" + fixed.items().get(0).amount() * multiplier
+ " #b8906e" + fixed.display(), fixed.commands(), fixed.items());
}
- // ====================================
- // daily-reward: paid once a day to a player in one of its groups, the
- // moment every task of today's draw is revealed. Called after a reveal
- // click and on every GUI open - the open is what retries a payout that
- // failed, and what pays a player who joined a group after revealing
- // everything. False when nothing was paid, which includes every case
- // where nothing is due.
- //
- // The item is resolved first, before anything is touched: a broken one
- // is refused without a disk write, since every open would otherwise pay
- // for a full players.yml save to find the same thing out. Then the same
- // burn-save-pay order as claim(): the flag is set and written before the
- // item goes out, so a crash cannot pay it twice, and handed back when
- // nothing reached the player, so the next open retries it. A player in no
- // listed group is not marked at all.
- //
- // A 'pool' group draws one entry from rewards.pool, paid as written - the
- // multiplier is not applied to it - and then goes through the same rules:
- // a drawn entry that has no commands and none of whose items resolve is
- // refused before anything is touched. An empty pool, like claim()'s, pays
- // nothing and is not marked.
- // ====================================
public boolean claimDailyReward(Player player) {
return claimDailyReward(player, path -> usable(resolveRewardItem(path)),
(reward, multiplier) -> dispatchRewards(player, reward, "daily reward", multiplier));
}
- // The resolve check and the payout passed in, so the rules above can be
- // driven headless
boolean claimDailyReward(Player player, Predicate resolvable,
BiPredicate pay) {
- // tasks(), not get(): a draw a reload has left short is made good
- // first, so it cannot count as "all revealed"
PlayerData data = tasks(player.getUniqueId());
if (data.dailyRewardClaimed() || !data.allRevealed()) {
return false;
@@ -807,18 +509,14 @@ boolean claimDailyReward(Player player, Predicate resolvable,
}
}
- // A command-only entry has nothing to resolve up front
boolean resolved = !reward.commands().isEmpty();
for (RewardEntry.Item item : reward.items()) {
try {
resolved |= resolvable.test(item.path());
} catch (Throwable t) {
- // not resolvable
}
}
if (!resolved) {
- // Once per path until a reload, like giveItems: this is reachable
- // on every open
for (RewardEntry.Item item : reward.items()) {
if (reportedItemPaths.add(item.path())) {
plugin.getLogger().warning("Daily reward item '" + Utils.safeForLog(item.path())
@@ -863,16 +561,6 @@ boolean claimDailyReward(Player player, Predicate resolvable,
return true;
}
- // ====================================
- // The reward of the first group, in config order, whose group.
- // permission the player has - LuckPerms grants that to its members. Null
- // when he is in none of them.
- //
- // The node must be explicitly set, not just answered true: an unset node
- // falls back to its default, which is OP, so hasPermission alone hands
- // every operator the top group's reward. A '*' grant can still set it -
- // see the daily-reward comment in config.yml.
- // ====================================
static RewardEntry dailyRewardFor(Map groups, Permissible player) {
for (Map.Entry group : groups.entrySet()) {
String node = "group." + group.getKey();
@@ -883,7 +571,6 @@ static RewardEntry dailyRewardFor(Map groups, Permissible p
return null;
}
- // One weighted draw from the pool. Null only on an empty pool.
private static RewardEntry draw(List pool) {
int total = RewardEntry.totalWeight(pool);
if (total <= 0) {
@@ -892,24 +579,11 @@ private static RewardEntry draw(List pool) {
return RewardEntry.pick(pool, ThreadLocalRandom.current().nextInt(total));
}
- // ====================================
- // What stays burned when only part of the payout went out: the highest of
- // the milestones that actually paid, or where this click started when
- // none did. Never hands back a milestone paid before this click. Takes the
- // max of the paid prefix rather than assuming 'due' is sorted ascending,
- // so it stays correct even if a caller hands it an unsorted list.
- // ====================================
static int rollbackClaimedPoints(int claimedBefore, int paid, List due) {
int cap = Math.min(paid, due.size());
return cap <= 0 ? claimedBefore : Collections.max(due.subList(0, cap));
}
- // ====================================
- // The pool entries that can pay this player at all: one whose commands can
- // be run for this name (see canRunCommand), or one that hands over
- // items - an item goes straight into the inventory and never has the name
- // pasted into it, so it pays a Bedrock/unsafe name like any other.
- // ====================================
static List runnableEntries(List pool, String name) {
List runnable = new ArrayList<>();
for (RewardEntry entry : pool) {
@@ -927,18 +601,6 @@ static List runnableEntries(List pool, String name) {
return runnable;
}
- // ====================================
- // One drawn entry handed over: its items, then its commands. True only if
- // something actually went out - the same rule dispatchCommands has always
- // used, now spanning both halves. An entry that gave its items but whose
- // commands all failed counts as paid on purpose: a milestone rolled back
- // is drawn and paid again on the next click, which would hand those items
- // over twice. A drawn entry that handed over nothing at all counts as
- // unpaid, so that milestone stays claimable.
- //
- // Both halves always run: the commands are not skipped just because the
- // items already succeeded.
- // ====================================
private boolean dispatchRewards(Player player, RewardEntry entry, String at, int multiplier) {
return dispatchRewards(
() -> giveItems(player, entry, multiplier, at,
@@ -946,39 +608,17 @@ private boolean dispatchRewards(Player player, RewardEntry entry, String at, int
() -> dispatchCommands(player, entry.commands(), "reward", plugin.getLogger(), CONSOLE));
}
- // The combine on its own, so the rule above can be pinned without a server
- // behind the command half
static boolean dispatchRewards(BooleanSupplier items, BooleanSupplier commands) {
boolean gaveItems = items.getAsBoolean();
boolean ranCommands = commands.getAsBoolean();
return gaveItems || ranCommands;
}
- // ====================================
- // The stack behind one configured reward path, or null when nothing can be
- // built from it. m. paths go through TLibs exactly as the GUI's icons do,
- // and only while all of TLibs/MMOItems/MythicLib are enabled - touching
- // TLibsItems without them would fail on class initialisation, which is not
- // something resolve() can catch for us.
- //
- // ia. paths go through ItemsAdder the same way, on its own gate. Null out
- // of either branch is the existing "nothing could be built" answer, which
- // giveItems already treats as nothing handed over - so an ItemsAdder item
- // that stops resolving leaves its milestone unclaimed rather than burning
- // it for nothing.
- // ====================================
private ItemStack resolveRewardItem(String path) {
- // Lambda, not a TLibsItems::item method reference: a reference would
- // resolve the TLibsItems class eagerly, ahead of the itemPathsUsable()
- // gate above, and reintroduce the class-init crash a server without
- // the TLibs trio would hit.
return resolveRewardItem(path, config.itemPathsUsable(), config.itemsAdderUsable(),
p -> TLibsItems.item(p), id -> ItemsAdderItems.item(id));
}
- // The branch selection on its own, so the two gates can be pinned headless
- // in all four combinations: each path form must survive the other form's
- // backing plugin being absent
static ItemStack resolveRewardItem(String path, boolean tlibs, boolean ia,
Function tlibsItems,
Function iaItems) {
@@ -993,25 +633,6 @@ static ItemStack resolveRewardItem(String path, boolean tlibs, boolean ia,
return material == null ? null : new ItemStack(material);
}
- // ====================================
- // Can an inventory actually hold this? A block-only material (CARROTS,
- // WATER, FIRE, POWDER_SNOW) is turned into nothing by addItem, which then
- // reports no leftover - the entry would count as paid, the milestone would
- // stay burned and the player would be told he was paid having received
- // nothing. Asked of the resolved stack rather than of the bare-Material
- // branch alone, so an m.. built on such a material is refused by
- // the same guard.
- //
- // Material#isItem() reads the item registry, which only exists on a
- // running server - headless it throws on class initialisation. There the
- // stack is taken at face value: this guard exists to catch a payout on a
- // real server, and refusing every stack without a registry would mean
- // handing nothing over at all.
- // ====================================
- // A resolved stack an inventory can actually be handed: not nothing, not
- // air, not a block-only material. Compared against AIR rather than
- // through Material#isAir(), which needs the block registry of a running
- // server.
static boolean usable(ItemStack stack) {
return stack != null && stack.getType() != Material.AIR && isItem(stack);
}
@@ -1024,36 +645,9 @@ static boolean isItem(ItemStack stack) {
}
}
- // ====================================
- // Hands the items of one entry over, and answers whether anything actually
- // reached the player - the same "did any of it go out" rule dispatchCommands
- // uses. A path that no longer resolves (TLibs down, the MMOItems id deleted
- // since load) is logged and counts as nothing handed over, so an entry made
- // only of such paths fails its milestone and leaves it claimable.
- //
- // What does not fit is dropped at the player's feet rather than lost: a
- // reward that vanishes into a full inventory is a support ticket. This runs
- // on the main thread - claim() is only ever reached from the GUI's
- // InventoryClickEvent handler - so the inventory is touched inline.
- //
- // The resolved stack's own amount is whatever built it, so the total is set
- // on a clone rather than multiplied: 'amount: 3' at multiplier 2 means six
- // items (a fixed drop or the daily reward; a pool spin is paid at multiplier 1,
- // i.e. its amounts as written). That
- // total goes over as whole stacks of at most the resolved item's own
- // getMaxStackSize(), so six swords are six stacks of one and 192 diamonds
- // are three of 64. The clone also keeps a stack TLibs
- // might be holding on to out of reach.
- // ====================================
- // 'at' names what is being paid in the log lines: "milestone 20", or
- // "daily reward".
static boolean giveItems(Player player, RewardEntry entry, int multiplier, String at,
Function resolver, Logger logger) {
boolean gaveAny = false;
- // Apart from gaveAny on purpose: gaveAny is set before addItem, so a
- // first insert that throws leaves it true having put nothing in the
- // inventory. Only a returned insert proves part of the entry actually
- // arrived, which is what the partial-payout warning below claims.
boolean insertedAny = false;
boolean missedAny = false;
for (RewardEntry.Item item : entry.items()) {
@@ -1062,10 +656,6 @@ static boolean giveItems(Player player, RewardEntry entry, int multiplier, Strin
ItemStack stack = resolver.apply(item.path());
if (!usable(stack)) {
missedAny = true;
- // Memoised the way TLibsItems memoises a failing path: a
- // claim can be repeated at click rate, and a path TLibs was
- // never asked about (item paths unusable) memoises nowhere
- // else
if (reportedItemPaths.add(item.path())) {
logger.warning("Reward item '" + Utils.safeForLog(item.path()) + "' (x" + total
+ ", " + at + ") could not be resolved for "
@@ -1073,25 +663,14 @@ static boolean giveItems(Player player, RewardEntry entry, int multiplier, Strin
}
continue;
}
- // The resolved item's own limit, not the vanilla 64: a stack of
- // 64 items whose max stack size is 1 is a dupe primitive in
- // every shift-click and crafting path that touches it after
int max = Math.max(1, stack.getMaxStackSize());
for (int left = total; left > 0; left -= max) {
ItemStack chunk = stack.clone();
chunk.setAmount(Math.min(left, max));
- // Set before the inventory is touched, not after: addItem
- // can throw having already filled some slots, and a
- // milestone rolled back then would be drawn and paid again
- // on the next click. A throwing drop must not un-pay it
- // either.
gaveAny = true;
Map leftovers = player.getInventory().addItem(chunk);
insertedAny = true;
for (ItemStack leftover : leftovers.values()) {
- // Owner-locked for the first seconds, so the overflow
- // cannot be picked up by whoever happens to be standing
- // next to the claimer
player.getWorld().dropItemNaturally(player.getLocation(), leftover, drop -> {
drop.setOwner(player.getUniqueId());
drop.setThrower(player.getUniqueId());
@@ -1099,19 +678,12 @@ static boolean giveItems(Player player, RewardEntry entry, int multiplier, Strin
}
}
} catch (Throwable t) {
- // Same reason dispatchCommands swallows: claim() has already
- // burned and saved the milestones and must be able to put them
- // back rather than unwind through this loop.
missedAny = true;
logger.severe("Reward item '" + Utils.safeForLog(item.path()) + "' (x" + total
+ ", " + at + ") threw for " + player.getUniqueId() + ": " + t);
}
}
- // Part of an entry went out and part did not. The milestone stays
- // burned on purpose - rolling it back would hand the part that did go
- // out over a second time - so the only way the rest ever reaches the
- // player is an operator reading this line.
if (insertedAny && missedAny) {
logger.warning("Only part of reward '" + Utils.safeForLog(entry.display()) + "' reached "
+ player.getUniqueId() + " at " + at + " - it stays claimed,"
@@ -1119,16 +691,8 @@ static boolean giveItems(Player player, RewardEntry entry, int multiplier, Strin
}
if (gaveAny) {
try {
- // The inventory was changed inside a cancelled
- // InventoryClickEvent, which leaves the client showing ghost
- // stacks until it reopens
player.updateInventory();
} catch (Throwable t) {
- // The one Bukkit call here that used to sit outside a try. It
- // must not unwind either: claim() has already burned and saved
- // the milestones and would skip its rollback, leaving disk
- // claiming milestones nobody was ever paid for. A stale client
- // view fixes itself on the next window open.
logger.warning("Could not resync the inventory of " + player.getUniqueId()
+ " after reward '" + Utils.safeForLog(entry.display()) + "' at " + at
+ ": " + t);
@@ -1137,32 +701,9 @@ static boolean giveItems(Player player, RewardEntry entry, int multiplier, Strin
return gaveAny;
}
- // What a dispatch actually runs on a live server. Passed in rather than
- // called directly so the loop below can be driven headless - the same
- // seam giveItems uses for its item resolver.
- // A Predicate rather than a Consumer so dispatchCommand's own answer is
- // captured: it returns false for a command the server does not know or
- // refused, which is otherwise indistinguishable from one that worked.
private static final Predicate CONSOLE =
command -> Bukkit.dispatchCommand(Bukkit.getConsoleSender(), command);
- // ====================================
- // True only if at least one command actually ran. The name check is per
- // command rather than per player: an unsafe name skips the ones that paste
- // it and leaves the %uuid%-only ones working.
- //
- // 'what' names the kind of command in the log line - "reward" for a
- // milestone payout, "click" for an activity's click-commands - so one
- // dispatcher serves both without either lying about the other.
- //
- // A command the console refused (dispatchCommand returned false: unknown,
- // or the plugin behind it said no) still counts as run, which is what it
- // has always counted as and what the reward payout's burn-save-pay rule
- // is built on - a milestone rolled back is drawn and paid again. It is
- // reported, though, and only once per kind and command: every other
- // outcome here is bounded by something, and this one is reachable on
- // every click of a stock config on a server without CMI or EssentialsX.
- // ====================================
static boolean dispatchCommands(Player player, List commands, String what, Logger logger,
Predicate console) {
String name = player.getName();
@@ -1177,15 +718,6 @@ static boolean dispatchCommands(Player player, List commands, String wha
continue;
}
- // ====================================
- // A configured command runs somebody else's plugin, which is free
- // to throw. Letting that unwind would leave claim() with the
- // thresholds already burned and saved and no chance to put them
- // back, so a throwing command counts as "did not run" and stops
- // the payout at the milestone it broke on. The click path needs
- // the same guard for a different reason: an InventoryClickEvent
- // handler that throws leaves the click half-handled.
- // ====================================
try {
if (!console.test(command.replace("%player%", name).replace("%uuid%", uuid))
&& reportOnce(what, command)) {
@@ -1195,9 +727,6 @@ && reportOnce(what, command)) {
}
ranAny = true;
} catch (Throwable t) {
- // Keyed apart from the refusal above: the two are different
- // failures, and sharing one key means a command that throws
- // once and is refused ever after is never reported refused
if (reportOnce(what + "-threw", command)) {
logger.severe("A " + what + " command '" + Utils.safeForLog(String.valueOf(command))
+ "' threw for " + Utils.safeForLog(name) + ": " + t + ". Not reported again until"
@@ -1209,37 +738,10 @@ && reportOnce(what, command)) {
return ranAny;
}
- // True the first time this kind of command is reported broken, false
- // every time after, until reload() empties the set
private static boolean reportOnce(String what, String command) {
return reportedBrokenCommands.add(what + "|" + command);
}
- // ====================================
- // An activity's 'click-commands', run for the player who clicked the task
- // in this slot (0-based). True only when something was actually
- // dispatched, so the caller knows whether to play its click sound.
- //
- // The slot is resolved and checked here rather than by the caller: the
- // whole safety of this path is that only a task the player has already
- // revealed can make the console run anything, and that invariant belongs
- // with the data it is about, the way reveal(UUID, int) owns its own slot
- // lookup. False covers a slot showing filler, an activity a reload has
- // dropped, a task that is still hidden, one that configures no
- // click-commands, and every refusal of the two rate limits below.
- //
- // Nothing is dispatched on this tick: the close and the dispatch are both
- // scheduled for the next one, by runClickCommandsNow below.
- //
- // So "true" here means scheduled, not dispatched - and the caller's click
- // sound plays on this tick, before the console has been asked anything.
- // Deliberate: the sound is the GUI's acknowledgement that the click was
- // accepted rather than refused by a rate limit, and that is exactly what
- // is known now. Waiting a tick to make it the result of the dispatch
- // would cost perceived responsiveness on the one control in this menu
- // that does something, and would still not mean much - a command the
- // console refuses counts as run here, so "dispatched" is not "worked".
- // ====================================
public boolean runClickCommands(Player player, int slot) {
PlayerData data = tasks(player.getUniqueId());
List drawn = data.tasks();
@@ -1253,14 +755,6 @@ public boolean runClickCommands(Player player, int slot) {
return false;
}
- // ====================================
- // What will actually be dispatched, decided before anything is
- // committed: a click whose every command is skipped by the name guard
- // ran nothing, and must not cost a sound, a closed menu or a place in
- // either rate limit. The per-click cap is here too, since the
- // cooldown bounds clicks and not commands - a 20-entry list would
- // otherwise multiply the cost of every click by twenty.
- // ====================================
List commands = runnableClickCommands(def, player.getName(), config.clickCommandsPerClick(),
plugin.getLogger());
if (commands.isEmpty()) {
@@ -1268,22 +762,9 @@ public boolean runClickCommands(Player player, int slot) {
}
long now = System.nanoTime();
- // Peeked before the cooldown is burned and taken after it passed, so
- // neither refusal costs the other anything: a click the server-wide
- // budget refused leaves the player's cooldown untouched, and one the
- // player's own cooldown refused does not spend a token other players
- // are queueing for
if (!clickBudget(now, config.clickCommandsPerSecond(), false)) {
return false;
}
- // ====================================
- // The cooldown is burned here, where the dispatch is committed, and
- // not for a click that ran nothing. A command that is dispatched and
- // then throws or is refused still burns it: the console has already
- // been made to do work, which is the thing being rate-limited, and
- // not burning it would let a permanently broken command be retried at
- // click rate - exactly what these limits exist to stop.
- // ====================================
if (!clickCooldownPassed(player.getUniqueId(), now,
config.clickCommandCooldownMillis() * 1_000_000L)) {
return false;
@@ -1293,26 +774,6 @@ public boolean runClickCommands(Player player, int slot) {
return schedule(() -> runClickCommandsNow(player, commands, id, CONSOLE));
}
- // ====================================
- // The tick after the click: close the menu, then dispatch. In that order
- // because config.yml promises the menu is gone before the command runs,
- // so a command that opens a GUI of its own keeps it; done the other way
- // round the close lands on whatever window the command opened and shuts
- // that instead.
- //
- // The window is identified by its holder and not by comparing the
- // InventoryView the click came from: nothing in Bukkit promises
- // getOpenInventory() hands back the same object twice, and a container
- // that returns a fresh wrapper per call would leave the menu silently
- // never closing. Asking the same question ActivityGui.onClick asks means
- // a player who closed the menu himself in the meantime keeps whatever he
- // opened instead, and a player who reopened the activity menu has that
- // one closed - which is the wanted outcome anyway.
- //
- // Package-private and taking the console call rather than reaching for
- // Bukkit, so the ordering and the audit line can be driven headless - the
- // same seam dispatchCommands uses.
- // ====================================
void runClickCommandsNow(Player player, List commands, String id, Predicate console) {
InventoryView open = player.getOpenInventory();
if (open != null && open.getTopInventory().getHolder() instanceof ActivityGui.Marker) {
@@ -1324,18 +785,6 @@ void runClickCommandsNow(Player player, List commands, String id, Predic
UUID uuid = player.getUniqueId();
dispatchCommands(player, commands, "click", logger, command -> {
- // ====================================
- // The audit line every staff command already writes, for the one
- // console dispatch a player can set off himself. A
- // console-dispatched command is not written to the server log the
- // way a player-issued one is, so without this nothing links the
- // player to what he caused.
- //
- // Emitted from a finally so a command that throws still leaves a
- // per-player trace - that being exactly the case an operator
- // would be investigating, and the one the once-per-uptime severe
- // above says the least about.
- // ====================================
String result = "threw";
try {
boolean ran = console.test(command);
@@ -1349,13 +798,6 @@ void runClickCommandsNow(Player player, List commands, String id, Predic
});
}
- // ====================================
- // The commands of one click that can actually be run for this name, at
- // most 'max' of them. The name guard is the dispatcher's own
- // (canRunCommand), asked early so a click that would run nothing can be
- // refused before it costs anything; the dispatcher asks again for each
- // one it runs, which is where the skip is logged.
- // ====================================
static List runnableClickCommands(ActivityDef def, String name, int max, Logger logger) {
if (def == null) {
return List.of();
@@ -1378,14 +820,6 @@ static List runnableClickCommands(ActivityDef def, String name, int max,
return runnable;
}
- // ====================================
- // Next tick's work, handed to the scheduler behind a guard: a plugin that
- // is disabling (a /reload or a shutdown mid-tick) throws
- // IllegalPluginAccessException out of runTask, which would unwind through
- // the click handler and leave the click half-handled - the exact failure
- // the ordering above exists to avoid. Nothing was dispatched then, so the
- // caller is told so.
- // ====================================
private boolean schedule(Runnable work) {
try {
plugin.getServer().getScheduler().runTask(plugin, work);
@@ -1396,27 +830,6 @@ private boolean schedule(Runnable work) {
}
}
- // ====================================
- // The server-wide ceiling, refilled at 'perSecond' tokens a second and
- // holding at most one second's worth, so a burst is bounded by the same
- // number it sustains. Peeked with take=false and spent with take=true.
- // Package-private and taking its clock reading so it can be pinned
- // without a clock to wait on.
- //
- // A token is a command and not a click: config.yml documents the knob as
- // a ceiling on commands, and a click dispatching five of them costs the
- // console five commands' worth of main thread.
- //
- // 'cost' is what a take spends, and the bucket is allowed to go negative
- // paying it - the gate is one whole token, not 'cost' of them. Refusing a
- // click outright when it wants more than the bucket holds would make a
- // legal configuration (click-commands-per-second below
- // click-commands-per-click) silently never run anything, forever, with no
- // diagnostic. Overdrawing instead keeps the long-run rate at exactly
- // 'perSecond': the debt has to be refilled before the next click is
- // allowed, and it is bounded by one click's cap (at most 50), so the
- // bucket always recovers.
- // ====================================
boolean clickBudget(long now, int perSecond, boolean take) {
return clickBudget(now, perSecond, 1, take);
}
@@ -1438,9 +851,6 @@ boolean clickBudget(long now, int perSecond, int cost, boolean take) {
return true;
}
- // The rate limit on its own, so it can be pinned without a clock to wait
- // on. Per player rather than per activity: what is being bounded is how
- // often one player can make the console run anything.
boolean clickCooldownPassed(UUID uuid, long now, long intervalNanos) {
Long last = clickCooldowns.get(uuid);
if (last != null && now - last < intervalNanos) {
@@ -1450,22 +860,14 @@ boolean clickCooldownPassed(UUID uuid, long now, long intervalNanos) {
return true;
}
- // ====================================
- // Called from the GUI's quit handler. Without it the map would keep one
- // entry per player who ever clicked a task, for the whole uptime.
- // ====================================
public void forgetClickCooldown(UUID uuid) {
clickCooldowns.remove(uuid);
}
- // Package-private for the eviction test; nothing else reads it
int clickCooldownEntries() {
return clickCooldowns.size();
}
- // A joiner who has never scored anything gets no entry: rolled() reads
- // what is already there rather than creating a row per player who ever
- // logged in
public void onJoin(Player player) {
PlayerData data = store.rolled(player.getUniqueId());
if (data != null && data.claimable(config.milestones()) > 0) {
@@ -1475,16 +877,9 @@ public void onJoin(Player player) {
public void reload() {
config.load();
- // An operator who fixed a reward pool deserves to hear about it again
- // if they got it wrong twice
warnedEmptyPools.clear();
reportedItemPaths.clear();
- // The hook's own memo of ids whose call threw is otherwise permanent
- // for the JVM: this is the only way short of a restart to give an id
- // another chance once ItemsAdder is back
ItemsAdderItems.reset();
- // An operator who fixed a broken command deserves to hear about it
- // again if it is still broken
reportedBrokenCommands.clear();
}
diff --git a/src/main/java/tfmc/justin/activity/models/ActivityDef.java b/src/main/java/tfmc/justin/activity/models/ActivityDef.java
index f4dda97..0e7261e 100644
--- a/src/main/java/tfmc/justin/activity/models/ActivityDef.java
+++ b/src/main/java/tfmc/justin/activity/models/ActivityDef.java
@@ -4,68 +4,33 @@
import java.util.List;
-// ====================================
-// every: actions per award. points: awarded each time 'every' is met.
-// dailyCap: most awards ('every' met) that count in one day, 0 = unlimited;
-// capPoints() is the same limit in points.
-// iconPath: the item path the GUI icon comes from - either the TLibs
-// m.. form or the normalized ItemsAdder ia. one,
-// which is what routes ActivityGui.fromPath to the right hook - or null when
-// the icon is the plain Material, in which case 'icon' is also the fallback
-// used if the backing plugin is missing or the path no longer resolves.
-// clickCommands: console commands run when the player clicks this activity's
-// task after it is already revealed. Empty for every activity that does not
-// configure 'click-commands', which is the normal case.
-// description: optional operator text shown at the top of the task's lore,
-// one entry per line. Empty for every activity that does not configure
-// 'description', which is the normal case.
-// ====================================
public record ActivityDef(String id, String display, Material icon, String iconPath, int every, int points,
int dailyCap, List clickCommands, List description) {
- // Immutable and never null, so neither the GUI nor the dispatcher has to
- // guard what config handed over
public ActivityDef {
clickCommands = clickCommands == null ? List.of() : List.copyOf(clickCommands);
description = description == null ? List.of() : List.copyOf(description);
}
- // The overwhelming majority of activities have no click commands and no
- // description; this keeps every one of those call sites reading the way it
- // always has. There is deliberately no 8-arg convenience constructor in
- // between: '..., dailyCap, List.of("&7Vote daily")' would compile and
- // register a description as a console command to dispatch.
public ActivityDef(String id, String display, Material icon, String iconPath, int every, int points,
int dailyCap) {
this(id, display, icon, iconPath, every, points, dailyCap, List.of(), List.of());
}
- // What a day's action count is worth in points, after every and dailyCap
public int worth(int count) {
int raw = rawWorth(count);
return dailyCap > 0 ? Math.min(raw, capPoints()) : raw;
}
- // dailyCap in points - the most worth() can reach in a day. Saturates like
- // rawWorth. Only meaningful when dailyCap > 0.
public int capPoints() {
return (int) Math.min(Integer.MAX_VALUE, (long) dailyCap * points);
}
- // How many times 'every' has been met today - the payout count, not its
- // point value. Clamped to dailyCap the same way worth() clamps to
- // capPoints(), just one factor of 'points' earlier.
public int completions(int count) {
int raw = count / every;
return dailyCap > 0 ? Math.min(raw, dailyCap) : raw;
}
- // ====================================
- // The same figure before dailyCap is applied - what /activity add --force
- // credits, since the whole point of the flag is that no cap holds it back.
- // Saturates rather than wrapping: a count near MAX_ADD times a big 'points'
- // overflows an int, and a negative worth would take points off the bar.
- // ====================================
public int rawWorth(int count) {
return (int) Math.min(Integer.MAX_VALUE, (long) (count / every) * points);
}
diff --git a/src/main/java/tfmc/justin/activity/models/PlayerData.java b/src/main/java/tfmc/justin/activity/models/PlayerData.java
index e0f8e98..3feda3f 100644
--- a/src/main/java/tfmc/justin/activity/models/PlayerData.java
+++ b/src/main/java/tfmc/justin/activity/models/PlayerData.java
@@ -12,56 +12,24 @@
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
-// ====================================
-// One player's week. Plain POJO with no Bukkit in it, so the rollover and
-// award rules are unit testable and the store stays a dumb serializer.
-// Mutated on the main thread only.
-// ====================================
public class PlayerData {
- // How many tasks a player is handed per day
public static final int TASKS_PER_DAY = 7;
- // ====================================
- // Written only on the main thread, but PlaceholderAPI reads a player's bar
- // off one - so the fields it touches are published safely rather than left
- // to whatever the reader's cache happens to hold.
- // ====================================
private final Map daily = new ConcurrentHashMap<>();
- // ====================================
- // Today's drawn task ids in slot order, and the ids of those the player
- // has revealed. Draws are distinct, so an id stands for its slot. Empty
- // means "not drawn yet today". Only a revealed task earns anything.
- // ====================================
private final List tasks = new CopyOnWriteArrayList<>();
private final Set revealed = ConcurrentHashMap.newKeySet();
private volatile int points;
- // Points accepted today across all activities; anything past dailyMax is
- // dropped and never reaches the weekly total
private volatile int dailyPoints;
- // The part of dailyPoints the vote activity put in through the gated
- // path - what bar.vote-share leaves out of the non-vote share. Never above
- // dailyPoints. A forced vote does not raise it, as it does not raise
- // dailyPoints either.
private volatile int votePoints;
private volatile String weekKey;
private volatile String dayKey;
- // ====================================
- // The highest milestone already paid out, not a milestone count: editing
- // bar.milestones and reloading must not make a milestone that was already
- // handed over claimable a second time.
- // ====================================
private volatile int claimedPoints;
- // Rerolls of today's draw already used. Reset by every rollover, so the
- // budget in config is per day.
private volatile int rerolls;
- // Whether today's daily-reward has been handed over. Reset by every
- // rollover like rerolls, but NOT by a reroll: a fresh draw revealed
- // again must not pay a second time the same day.
private volatile boolean dailyRewardClaimed;
public PlayerData(String weekKey, String dayKey) {
@@ -94,20 +62,12 @@ public PlayerData(int points, int dailyPoints, String weekKey, String dayKey, in
}
}
- // The store's constructor: same as above plus the persisted reroll count.
- // Overloaded rather than added to the others so every existing caller that
- // has no reroll count to give keeps working.
public PlayerData(int points, int dailyPoints, String weekKey, String dayKey, int claimedPoints,
Map daily, List tasks, Collection revealed, int rerolls) {
this(points, dailyPoints, weekKey, dayKey, claimedPoints, daily, tasks, revealed);
this.rerolls = rerolls;
}
- // ====================================
- // Lazy rollover, called before anything reads or writes this player.
- // A new week wipes everything; a new day only wipes the daily counters,
- // so points earned earlier in the week survive.
- // ====================================
public boolean roll(String weekKey, String dayKey) {
boolean changed = false;
@@ -138,32 +98,17 @@ public boolean roll(String weekKey, String dayKey) {
return changed;
}
- // ====================================
- // Points come from the change in "what today's count is worth", so no
- // per-day awarded counter has to be kept in sync with the action count.
- // ponytail: changing every/dailyCap mid-day can under- or over-award that
- // day by the difference; acceptable
- // ====================================
public RecordResult record(int amount, ActivityDef def, int max, int dailyMax, List milestones) {
return record(amount, def, max, dailyMax, dailyMax, milestones);
}
- // ====================================
- // Same, with bar.vote-share: every activity but vote shares nonVoteMax of
- // the day, and today's non-vote points are dailyPoints minus votePoints.
- // A nonVoteMax equal to dailyMax is no share at all.
- // ====================================
public RecordResult record(int amount, ActivityDef def, int max, int dailyMax, int nonVoteMax,
List milestones) {
int before = daily.getOrDefault(def.id(), 0);
- // Saturate: a bogus /activity add 2000000000 twice must not wrap the
- // counter negative and hand out awards all over again
int after = (int) Math.min(Integer.MAX_VALUE, (long) before + amount);
daily.put(def.id(), after);
int earned = def.worth(after) - def.worth(before);
- // Everything past today's budget is lost outright - it must not reach
- // the weekly bar, today or later
int dailyBudget = Math.max(0, dailyMax - dailyPoints);
boolean isVote = def.id().equals("vote");
int budget = isVote ? dailyBudget
@@ -173,8 +118,6 @@ public RecordResult record(int amount, ActivityDef def, int max, int dailyMax, i
}
earned = Math.min(earned, budget);
if (earned <= 0) {
- // Either the activity has nothing more to give today, or the count
- // is simply part-way to its next point - which is a plain success
return new RecordResult(0, 0,
def.dailyCap() > 0 && def.worth(before) >= def.capPoints()
? Recorded.ACTIVITY_CAP : Recorded.RECORDED);
@@ -193,23 +136,6 @@ public RecordResult record(int amount, ActivityDef def, int max, int dailyMax, i
Recorded.RECORDED);
}
- // ====================================
- // /activity add --force only. The count goes in exactly as record() puts
- // it in, but the award ignores both the activity's dailyCap and today's
- // bar.daily-max budget: staff asking for 50 get 50.
- //
- // bar.max is NOT ignored. PlayerStore.parse clamps stored points to it on
- // load, so a bar past its maximum would be silently cut back at the next
- // restart - memory and disk disagreeing about a live player. An award the
- // bar cuts short comes back as WEEKLY_CLAMPED so the admin is told.
- //
- // dailyPoints is deliberately left alone rather than raised past
- // bar.daily-max, which parse() clamps just as hard. The consequences are
- // the right ones: a forced award does not eat the player's real budget for
- // the day, does not trip the reroll.max-points gate, and is not handed
- // back off the bar by a later reroll - a reroll refunds today's genuinely
- // earned points only, which is what it is for.
- // ====================================
public RecordResult recordForced(int amount, ActivityDef def, int max, List milestones) {
int before = daily.getOrDefault(def.id(), 0);
int after = (int) Math.min(Integer.MAX_VALUE, (long) before + amount);
@@ -217,17 +143,11 @@ public RecordResult recordForced(int amount, ActivityDef def, int max, List milestones) {
int pointsBefore = points;
addPoints(earned, max);
@@ -239,16 +159,10 @@ public RecordResult creditForced(int earned, int max, List