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 milestones) awarded < earned ? Recorded.WEEKLY_CLAMPED : Recorded.RECORDED); } - // Widened to long before the sum: a forced add hands over an award that - // saturates at Integer.MAX_VALUE, and 'points + p' as an int would wrap - // negative and Math.max(0, ..) would then wipe the bar instead of filling - // it. Every value that did not overflow before is unaffected. public void addPoints(int p, int max) { points = (int) Math.max(0, Math.min(max, (long) points + p)); } - // Stored points can exceed the bar after bar.max is lowered or the file - // came from an older scale; claimable and percent both assume they do not public boolean clamp(int max) { if (points <= max) { return false; @@ -257,11 +171,6 @@ public boolean clamp(int max) { return true; } - // ==================================== - // The milestones this bar has reached and not yet been paid for, lowest - // first - the order claim() hands them over in. Static and pure so the - // manager can walk the same list it counts. - // ==================================== public static List due(int points, int claimedPoints, List milestones) { List due = new ArrayList<>(); for (Integer milestone : milestones) { @@ -269,8 +178,6 @@ public static List due(int points, int claimedPoints, List mil due.add(milestone); } } - // Config order is not guaranteed ascending; claim() pays these in - // order and burns claimedPoints up to the highest one. due.sort(null); return due; } @@ -287,8 +194,6 @@ public void reset(String weekKey, String dayKey) { daily.clear(); clearTasks(); rerolls = 0; - // Kept when the reset lands on the same day: the reward was really - // handed over, and a fresh draw revealed again must not pay it twice if (!dayKey.equals(this.dayKey) || !weekKey.equals(this.weekKey)) { dailyRewardClaimed = false; } @@ -296,38 +201,12 @@ public void reset(String weekKey, String dayKey) { this.dayKey = dayKey; } - // ==================================== - // Throws today away and starts it over on a fresh draw: today's unclaimed - // points come back off the weekly bar, the counts and the draw go, and the - // new tasks go in unrevealed. One method rather than a handful the caller - // sequences, so it is all written in a single call on the main thread - - // there is no lock, so an off-thread reader (PlaceholderAPI) can still - // observe an intermediate pair of fields. - // - // The weekly total never falls below claimedPoints: those points have - // already been paid for, and letting the bar drop under them would make - // the same milestone claimable a second time. It never rises above the - // bar either - claimedPoints can sit above a lowered bar.max. - // - // Whatever that floor kept on the bar is NOT refunded, so it must not hand - // today's budget back either: only the points actually taken off the bar - // buy back budget, and the rest carries forward as today's starting - // dailyPoints. A player who claims a milestone and then rerolls therefore - // keeps their points and gets no extra budget at all. The per-activity - // count map still goes - those tasks no longer exist. - // ==================================== public void reroll(List newTasks, int max) { int before = points; points = Math.min(max, Math.max(claimedPoints, points - dailyPoints)); - // The refund is clamped to zero before it is subtracted: if a reroll - // ever RAISES points (claimedPoints sitting above a bar.max that was - // lowered and then raised back), before - points is negative and - // must not be allowed to increase dailyPoints instead. int refund = Math.max(0, before - points); int dailyBefore = dailyPoints; dailyPoints = Math.max(0, dailyPoints - refund); - // The carried points keep the day's vote/non-vote mix, so votes carried - // past a reroll still do not count against the non-vote share votePoints = dailyBefore == 0 ? 0 : (int) ((long) votePoints * dailyPoints / dailyBefore); daily.clear(); clearTasks(); @@ -340,22 +219,8 @@ private void clearTasks() { revealed.clear(); } - // ==================================== - // Up to TASKS_PER_DAY distinct ids out of the loaded ones, in random - // order. Fewer than that loaded: all of them. Pure so it can be tested - // with a seeded Random. - // - // Every id in 'guaranteed' that is actually loaded is always in the draw - // (that is the whole point of the flag), but the finished list is shuffled - // as a whole, so a guaranteed task lands in a random slot rather than - // always the first. More guaranteed ids than slots: the draw is - // TASKS_PER_DAY of them, picked and ordered at random, and no other - // activity can get in - config warns about that at load. - // ==================================== public static List draw(Collection ids, Collection guaranteed, Random random) { List forced = new ArrayList<>(new LinkedHashSet<>(guaranteed)); - // An id whose plugin is missing never made it into the loaded set, so - // it simply cannot be drawn forced.retainAll(new HashSet<>(ids)); Collections.shuffle(forced, random); if (forced.size() >= TASKS_PER_DAY) { @@ -372,24 +237,7 @@ public static List draw(Collection ids, Collection guara return List.copyOf(drawn); } - // ==================================== - // Brings the draw in line with what is actually loaded: an id whose - // activity a reload has removed is dropped along with its revealed flag, - // and the draw is then topped back up to TASKS_PER_DAY with ids it does - // not already hold. Survivors keep their order (the list compacts, so a - // drop shifts the slots after it); a refilled task is always unrevealed. - // A guaranteed id that is loaded but missing from the draw is put back - // here too - a draw persisted before the flag existed, or one a reload - // dropped it out of, must not leave the player without it for the day. - // The rest of the draw is left alone rather than re-rolled: it is slotted - // in at a random position, and only when the draw is already full does it - // take a slot over, preferring an unrevealed one so as little progress as - // possible is lost. - // True if anything changed, which is what the caller saves on. - // ==================================== public boolean ensureTasks(Collection ids, Collection guaranteed, Random random) { - // First use today: one whole draw, so guaranteed tasks are placed at - // random rather than slotted into an existing order if (tasks.isEmpty()) { tasks.addAll(draw(ids, guaranteed, random)); return !tasks.isEmpty(); @@ -405,8 +253,6 @@ public boolean ensureTasks(Collection ids, Collection guaranteed return changed; } - // The refill is a fresh draw out of what is not already held: at most - // TASKS_PER_DAY ids come back, which is always enough to top up List pool = new ArrayList<>(known); pool.removeAll(tasks); for (String id : draw(pool, List.of(), random)) { @@ -419,14 +265,6 @@ public boolean ensureTasks(Collection ids, Collection guaranteed return changed; } - // ==================================== - // Puts every loaded guaranteed id the draw is missing back into it: at a - // random free slot while there is room, otherwise over a slot chosen at - // random among the unrevealed ones (all revealed: any slot), whose - // revealed flag goes with it. Slots already holding a guaranteed id are - // never taken over, so more guaranteed ids than slots settles on - // TASKS_PER_DAY of them instead of churning. - // ==================================== private boolean restoreGuaranteed(Set known, Collection guaranteed, Random random) { Set forced = new LinkedHashSet<>(guaranteed); boolean changed = false; @@ -458,8 +296,6 @@ private boolean restoreGuaranteed(Set known, Collection guarante return changed; } - // Reveals the task in this slot. False for an empty slot or one already - // revealed, so the caller knows whether anything changed. public boolean reveal(int slot) { if (slot < 0 || slot >= tasks.size()) { return false; @@ -467,7 +303,6 @@ public boolean reveal(int slot) { return revealed.add(tasks.get(slot)); } - // Whether this activity is one of today's tasks and has been revealed public boolean isRevealed(String id) { return revealed.contains(id); } @@ -492,8 +327,6 @@ public int votePoints() { return votePoints; } - // The store's, for the persisted value; clamped to 0..dailyPoints so the - // non-vote share can never read as negative or past the day public void setVotePoints(int votePoints) { this.votePoints = Math.max(0, Math.min(votePoints, dailyPoints)); } @@ -518,12 +351,6 @@ public int rerolls() { return rerolls; } - // ==================================== - // Hands one consumed reroll back, floored at zero - /activity givereroll - // , for a player who burned theirs on a misclick. Nothing else - // about the day is touched: the draw and the points stay as they are. - // True if the counter actually moved. - // ==================================== public boolean refundReroll() { if (rerolls <= 0) { return false; @@ -540,8 +367,6 @@ public void setDailyRewardClaimed(boolean dailyRewardClaimed) { this.dailyRewardClaimed = dailyRewardClaimed; } - // Every task of today's draw revealed - what daily-reward pays for. An - // empty draw is not "all revealed": there is nothing to have done. public boolean allRevealed() { return !tasks.isEmpty() && revealed.containsAll(tasks); } diff --git a/src/main/java/tfmc/justin/activity/models/RecordResult.java b/src/main/java/tfmc/justin/activity/models/RecordResult.java index ebcdfb8..909a2a8 100644 --- a/src/main/java/tfmc/justin/activity/models/RecordResult.java +++ b/src/main/java/tfmc/justin/activity/models/RecordResult.java @@ -1,5 +1,4 @@ package tfmc.justin.activity.models; -// 'outcome' is what the points (or the lack of them) mean - see Recorded public record RecordResult(int pointsAwarded, int milestonesReached, Recorded outcome) { } diff --git a/src/main/java/tfmc/justin/activity/models/Recorded.java b/src/main/java/tfmc/justin/activity/models/Recorded.java index c469242..c9f454a 100644 --- a/src/main/java/tfmc/justin/activity/models/Recorded.java +++ b/src/main/java/tfmc/justin/activity/models/Recorded.java @@ -1,39 +1,20 @@ package tfmc.justin.activity.models; -// ==================================== -// What one record attempt did. Listeners and the API ignore it; /activity add -// turns it into the line the admin reads, which is why "the count went in but -// no point reached the bar" names the cap that swallowed it rather than -// reporting a plain success. -// ==================================== public enum Recorded { - // No such activity, or a non-positive amount UNKNOWN, - // Gated, and not one of the player's revealed tasks today (which covers a - // player with no draw at all) NOT_A_TASK, - // The count went in. A point may or may not have landed with it - an - // activity part-way to its next point is still a plain success. RECORDED, - // This activity's own daily cap was already reached ACTIVITY_CAP, - // Today's point budget (bar.daily-max) was already spent DAILY_MAX, - // Not vote, and every other activity together has already added its - // share of the day (bar.daily-max less bar.vote-share); voting still can VOTE_SHARE, - // The weekly bar (bar.max) was already full WEEKLY_MAX, - // Points landed, but fewer than were earned: the weekly bar (bar.max) - // filled up part-way through. Only a forced add can produce this - the - // gated path spends its whole award or none of it. WEEKLY_CLAMPED } diff --git a/src/main/java/tfmc/justin/activity/models/RewardEntry.java b/src/main/java/tfmc/justin/activity/models/RewardEntry.java index 78842a5..0b5e098 100644 --- a/src/main/java/tfmc/justin/activity/models/RewardEntry.java +++ b/src/main/java/tfmc/justin/activity/models/RewardEntry.java @@ -2,16 +2,8 @@ import java.util.List; -// ==================================== -// One entry of the reward pool: the console commands and the items one claim -// can hand over, what to call them in chat, and how likely it is to be drawn. -// Plain POJO with no Bukkit in it, so the draw stays unit testable - an item -// is held as its configured path and amount, not as a resolved ItemStack. -// ==================================== public record RewardEntry(int weight, String display, List commands, List items) { - // One 'items:' line: the item-path form ItemPath understands, and how many - // of it to hand over. public record Item(String path, int amount) { } @@ -23,12 +15,6 @@ public static int totalWeight(List pool) { return (int) Math.min(Integer.MAX_VALUE, total); } - // ==================================== - // The entry a roll in [0, totalWeight) lands on: cumulative weights, so - // an entry of weight 3 is drawn three times as often as one of weight 1. - // A roll outside the range (a caller that rolled against a stale total) - // takes the last entry rather than paying nothing. Null on an empty pool. - // ==================================== public static RewardEntry pick(List pool, int roll) { if (pool.isEmpty()) { return null; diff --git a/src/main/java/tfmc/justin/activity/store/PlayerStore.java b/src/main/java/tfmc/justin/activity/store/PlayerStore.java index c40dab3..febfbf3 100644 --- a/src/main/java/tfmc/justin/activity/store/PlayerStore.java +++ b/src/main/java/tfmc/justin/activity/store/PlayerStore.java @@ -25,53 +25,23 @@ import java.util.concurrent.atomic.AtomicLong; import java.util.function.Predicate; -// ==================================== -// players.yml. Everything lives in memory and is mutated on the main thread: -// get() rolls a player forward and creates the entry, so only write paths may -// call it; readers that must not mutate anything (PlaceholderAPI) use peek(). -// -// Snapshots are taken on a tick and written off one, but the autosave, the -// post-reward save and onDisable can all want the file at once, so every -// write goes through saveLock and lands via a temp file + atomic rename. -// Each snapshot carries a sequence number so a slow writer cannot land an -// older picture of the world on top of a newer one. -// ==================================== public class PlayerStore { - // Public so a log line elsewhere can name the file it is talking about public static final String FILE = "players.yml"; private final JavaPlugin plugin; private final ActivityConfiguration config; private final File file; - // Concurrent because PlaceholderAPI can ask for a player's bar off the - // main thread; every mutation of the PlayerData itself stays on a tick private final Map players = new ConcurrentHashMap<>(); - // One writer at a time, whichever thread it is on private final Object saveLock = new Object(); - // Set on a tick, read by the autosave task - and by the async writer private volatile boolean dirty; private BukkitTask autoSave; - // Nothing may be written before load() has said what is already on disk, - // or an enable that failed early would save an empty store over a real file private volatile boolean loaded; - // Set on the main thread by shutdown; volatile so a save path the - // scheduler runs late still sees that the final write has been taken private volatile boolean shuttingDown; - // ==================================== - // saveSeq is stamped on each snapshot in the order the snapshots were - // taken - it's an AtomicLong so a snapshot can be sequenced without - // holding saveLock, which is reserved for the write() section that - // actually touches disk. lastWritten is the newest sequence that has - // reached disk, guarded by saveLock alongside the write it orders against, - // so a write that lost the race is dropped rather than rolling the file - // back. The final save takes the highest number of all, which is what - // makes every straggler behind it a no-op. - // ==================================== private final AtomicLong saveSeq = new AtomicLong(); private long lastWritten; @@ -83,20 +53,14 @@ public PlayerStore(JavaPlugin plugin, ActivityConfiguration config) { public void load() { players.clear(); - // A file that could not even be copied aside must stay untouched: with - // loaded left false, write() and shutdown() both refuse to save, so the - // original survives until somebody looks at it if (file.exists() && !read()) { plugin.getLogger().severe("Saving is disabled until an operator moves " + FILE + " aside by hand. Nothing earned from now on will be kept."); return; } - // Only now may anything be written back over the file loaded = true; } - // Returns false only when the existing file could neither be used nor - // safely copied aside private boolean read() { YamlConfiguration yaml = new YamlConfiguration(); try { @@ -107,10 +71,6 @@ private boolean read() { ConfigurationSection root = yaml.getConfigurationSection("players"); if (root == null) { - // Other top-level keys but no 'players' means the file is somebody - // else's, or ours with its one section lost. A comment-only file, - // or one holding an empty 'players', is just a server nobody has - // scored on yet - and overwriting that costs nothing. Set stray = yaml.getKeys(false); stray.remove("players"); if (!stray.isEmpty()) { @@ -125,10 +85,6 @@ private boolean read() { return true; } - // ==================================== - // Section and key are resolved once, here, because this is also the side - // that has a logger to say why a row was skipped. - // ==================================== private void readEntry(ConfigurationSection root, String key) { ConfigurationSection entry = root.getConfigurationSection(key); if (entry == null) { @@ -147,19 +103,11 @@ private void readEntry(ConfigurationSection root, String key) { config.activity("vote"))); } - // ==================================== - // Test seam over the instance path above: the same "skip this row" rules - - // a missing section or a key that is not a UUID - reported as null rather - // than as a warning, so they can be exercised without a JavaPlugin. - // 'known' says which activity ids are loaded; any other task id is dropped. - // ==================================== static PlayerData readEntry(ConfigurationSection root, String key, int barMax, int dailyMax, Predicate known) { return readEntry(root, key, barMax, dailyMax, known, null); } - // Same, with the loaded vote activity (or null) for a row saved before - // vote-points existed static PlayerData readEntry(ConfigurationSection root, String key, int barMax, int dailyMax, Predicate known, ActivityDef vote) { ConfigurationSection entry = root.getConfigurationSection(key); @@ -176,9 +124,6 @@ static PlayerData readEntry(ConfigurationSection root, String key, int barMax, i return parse(entry, barMax, dailyMax, known, vote); } - // ==================================== - // One entry's values, with the key already dealt with by the caller. - // ==================================== private static PlayerData parse(ConfigurationSection entry, int barMax, int dailyMax, Predicate known, ActivityDef vote) { Map daily = new HashMap<>(); @@ -189,46 +134,23 @@ private static PlayerData parse(ConfigurationSection entry, int barMax, int dail } } - // ==================================== - // Nothing read off disk is trusted: a hand-edited or garbled file must - // not be able to produce a negative bar, or claimed-points behind the - // points that were supposedly paid for, which would read as free - // rewards nobody earned. - // ==================================== int points = Math.max(0, Math.min(entry.getInt("points"), barMax)); int claimedPoints = Math.max(0, Math.min(entry.getInt("claimed-points"), points)); int dailyPoints = Math.max(0, Math.min(entry.getInt("daily-points"), dailyMax)); - // A task whose activity was removed from config.yml is dropped; the - // rest keep their order and revealed state. The short draw is topped - // back up by PlayerData.ensureTasks the next time it is used, which is - // also what happens to a draw that was already in memory - both sides - // end up with the same rule. List tasks = new ArrayList<>(entry.getStringList("tasks")); tasks.removeIf(known.negate()); - // A negative count off a hand-edited file would put the player under - // zero rerolls used, i.e. hand them extra ones past reroll.per-day; - // there is no upper bound to enforce here, the budget lives in config int rerolls = Math.max(0, entry.getInt("rerolls")); PlayerData data = new PlayerData(points, dailyPoints, entry.getString("week", ""), entry.getString("day", ""), claimedPoints, daily, tasks, entry.getStringList("revealed"), rerolls); - // A row written before daily-reward existed has no key: not claimed data.setDailyRewardClaimed(entry.getBoolean("daily-reward-claimed", false)); - // A row written before vote-share existed has no key: today's vote - // count's worth is the best guess of what voting put in. setVotePoints - // clamps it to dailyPoints, and nothing already earned is taken back. data.setVotePoints(entry.contains("vote-points") ? entry.getInt("vote-points") : vote == null ? 0 : vote.worth(daily.getOrDefault(vote.id(), 0))); return data; } - // ==================================== - // A players.yml that cannot be parsed, or that holds something other than - // our data, is copied aside and reported loudly rather than being silently - // overwritten by the next save. - // ==================================== private boolean quarantine(String reason) { File backup = new File(file.getParentFile(), FILE + ".corrupt-" + System.currentTimeMillis()); try { @@ -242,11 +164,6 @@ private boolean quarantine(String reason) { } } - // ==================================== - // The entry point for write paths: whatever asks for it gets data that has - // already been rolled forward to the current week and day, creating the - // entry if this is the player's first action. - // ==================================== public PlayerData get(UUID uuid) { PlayerData data = rolled(uuid); if (data != null) { @@ -260,11 +177,6 @@ public PlayerData get(UUID uuid) { return data; } - // ==================================== - // get() without the create: for callers that want current numbers for a - // player who may never have scored anything, and must not put them in the - // file just for asking. Main thread only, since it mutates what it finds. - // ==================================== public PlayerData rolled(UUID uuid) { PlayerData data = players.get(uuid); if (data == null) { @@ -272,20 +184,12 @@ public PlayerData rolled(UUID uuid) { } ActivityConfiguration.Keys keys = config.currentKeys(); - // Clamp after the roll: a reload that lowered bar.max must not leave a - // player above it if (data.roll(keys.week(), keys.day()) | data.clamp(config.barMax())) { dirty = true; } return data; } - // ==================================== - // Read-only counterpart of get(): no entry is created and nothing is - // rolled, so a placeholder query on an arbitrary (possibly off-thread) - // player cannot grow the file or mutate state. Callers compare the - // returned keys against the current ones themselves. - // ==================================== public PlayerData peek(UUID uuid) { return players.get(uuid); } @@ -303,16 +207,6 @@ public void startAutoSave() { }, ticks, ticks); } - // ==================================== - // Snapshot on the main thread, write off it - the map must not be read - // while a tick could be mutating it. Once shutdown has taken its own final - // snapshot there is nothing left worth queueing behind it. - // - // The snapshot and its sequence number are taken outside saveLock: - // snapshots only ever happen on the main thread, so call order alone - // keeps them ordered, and saveLock is left free to guard only the - // write() section that actually touches disk. - // ==================================== public void saveSoon() { if (shuttingDown) { return; @@ -322,8 +216,6 @@ public void saveSoon() { long seq = saveSeq.incrementAndGet(); dirty = false; - // The scheduler refuses work from a disabled plugin, so a save asked - // for while the server is pulling us down has to happen right here if (!plugin.isEnabled()) { write(snapshot, seq); return; @@ -332,14 +224,6 @@ public void saveSoon() { plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> write(snapshot, seq)); } - // ==================================== - // dirty only clears on a write that actually landed - a failed write must - // leave it set so the next autosave tick tries again instead of the - // in-memory changes silently going unsaved. - // - // The result is returned rather than swallowed: the claim path pays out - // only once the thresholds it burned are known to be on disk. - // ==================================== public boolean saveNow() { if (!write(snapshot(), saveSeq.incrementAndGet())) { return false; @@ -348,15 +232,11 @@ public boolean saveNow() { return true; } - // Whether anything written now would actually reach disk - false means a - // corrupt file was never read and every save for this session is refused public boolean isLoaded() { return loaded; } public void shutdown() { - // Stops the autosave and saveSoon from queueing anything the final - // write would then have to outrank shuttingDown = true; if (!loaded) { @@ -365,8 +245,6 @@ public void shutdown() { return; } - // Under the lock so a still-running async write finishes before the - // final one starts, and no new autosave can slip in behind it synchronized (saveLock) { if (autoSave != null) { autoSave.cancel(); @@ -380,19 +258,10 @@ private YamlConfiguration snapshot() { return snapshot(players); } - // ==================================== - // Pure over the map it is handed, so the "an empty player is no player" - // rule can be exercised without a store. - // ==================================== static YamlConfiguration snapshot(Map players) { YamlConfiguration yaml = new YamlConfiguration(); for (Map.Entry entry : players.entrySet()) { PlayerData data = entry.getValue(); - // A player who has nothing is the same as a player with no entry, - // and writing one per joiner grows the file for no reason. - // dailyPoints() == 0 is implied by daily().isEmpty() (and vice - // versa) but is checked explicitly too - belt and braces. - // Today's draw is kept too, or a restart would re-roll it. if (data.points() == 0 && data.claimedPoints() == 0 && data.dailyPoints() == 0 && data.daily().isEmpty() && data.tasks().isEmpty() && data.rerolls() == 0 && !data.dailyRewardClaimed()) { @@ -411,22 +280,11 @@ static YamlConfiguration snapshot(Map players) { yaml.set(path + ".daily-reward-claimed", data.dailyRewardClaimed()); yaml.set(path + ".daily", new LinkedHashMap<>(data.daily())); yaml.set(path + ".tasks", new ArrayList<>(data.tasks())); - // Sorted, because revealed() is a hash set whose iteration order - // would otherwise reshuffle this list between saves and churn the - // file for no change at all yaml.set(path + ".revealed", new ArrayList<>(new TreeSet<>(data.revealed()))); } return yaml; } - // ==================================== - // Never write players.yml in place: a crash mid-write would leave a - // truncated file where the whole server's progress used to be. The temp - // file is the one that can be half-written, the rename is what publishes. - // - // Returns true only once the snapshot has actually landed on disk, so a - // caller knows whether it is safe to clear dirty. - // ==================================== private boolean write(YamlConfiguration snapshot, long seq) { synchronized (saveLock) { if (!loaded) { @@ -434,9 +292,6 @@ private boolean write(YamlConfiguration snapshot, long seq) { return false; } - // An older snapshot arriving late - a slow async write, or one - // queued before shutdown took the last picture - would undo work. - // Not a failure, just superseded, so no dirty flag is touched. if (seq < lastWritten) { return false; } @@ -454,8 +309,6 @@ private boolean write(YamlConfiguration snapshot, long seq) { return true; } catch (IOException e) { plugin.getLogger().severe("Failed to write " + FILE + ": " + e.getMessage()); - // Nothing landed, so the in-memory state is still unsaved and - // the next autosave tick has to try again dirty = true; return false; } diff --git a/src/main/java/tfmc/justin/activity/utils/Bar.java b/src/main/java/tfmc/justin/activity/utils/Bar.java index c7432dd..e84e9e6 100644 --- a/src/main/java/tfmc/justin/activity/utils/Bar.java +++ b/src/main/java/tfmc/justin/activity/utils/Bar.java @@ -3,28 +3,11 @@ import java.util.List; import java.util.Locale; -// ==================================== -// Marketblock's demand bar: '|' segments between gray brackets, the whole run -// tinted by how full it is - dark red at empty, green at full, with the unfilled -// segments in the same colour at a third of the brightness. -// -// Returns raw '#rrggbb' codes rather than colorized text so it stays free of -// Bukkit and can be unit tested. Callers pass it through Utils.colorize. -// ==================================== public final class Bar { - // Endpoint colours matching the reference DemandFormatter: dark red at - // empty, green at full. private static final int R1 = 0xaa, G1 = 0x00, B1 = 0x00; private static final int R2 = 0x00, G2 = 0xff, B2 = 0x00; - // ==================================== - // A milestone segment. A marker drawn as '|' in the run's colour is - // invisible, which is the one thing it must not be, so it is the heavy - // vertical bar in a fixed colour of its own: gold while the milestone is - // still ahead, aqua once the value has reached it. Reached is judged on - // the value, not the rounded fill, which can cover a marker a point early. - // ==================================== private static final char SEGMENT = '|'; private static final char MARKER = '┃'; static final String MARKER_AHEAD = "#ffd700"; @@ -37,14 +20,6 @@ public static String render(int value, int max, int length) { return render(value, max, length, List.of()); } - // ==================================== - // The same bar with a marker on the segment each milestone sits at, so a - // player can see where the rewards are. The marker sits one segment - // before round(milestone / max * length): that is the segment which - // fills the instant the milestone is reached, rather than the first - // segment still to come. A milestone landing exactly on the end of the - // bar marks the last segment rather than falling off it. - // ==================================== public static String render(int value, int max, int length, List milestones) { double ratio = max <= 0 ? 0 : (double) value / max; ratio = Math.max(0.0, Math.min(1.0, ratio)); @@ -54,8 +29,6 @@ public static String render(int value, int max, int length, List milest int g = (int) Math.round(G1 + (G2 - G1) * ratio); int b = (int) Math.round(B1 + (B2 - B1) * ratio); - // The highest milestone on each segment, 0 for none: a shared segment - // only shows as reached once every milestone on it is int[] markers = new int[length]; if (max > 0 && length > 0) { for (Integer milestone : milestones) { diff --git a/src/main/java/tfmc/justin/activity/utils/ItemPath.java b/src/main/java/tfmc/justin/activity/utils/ItemPath.java index 1e8b234..11f16f0 100644 --- a/src/main/java/tfmc/justin/activity/utils/ItemPath.java +++ b/src/main/java/tfmc/justin/activity/utils/ItemPath.java @@ -2,37 +2,11 @@ import org.bukkit.Material; -// ==================================== -// The forms an item-valued config key accepts - four for 'material:' and for -// a reward 'item:', three for 'craft:', which refuses ia. outright - all -// classified here so every key reads them the same way: -// -// IRON_INGOT a bare Material name, as before -// v.iron_ingot the same thing in TLibs path notation -// m.material.steel an MMOItems item, resolved through TLibs at runtime -// ia.tfmc:saucepan an ItemsAdder item, resolved through ItemsAdder at -// runtime - 'ia.tfmc.saucepan' is the same item written -// with a dot, and normalizes to the colon form -// -// A Material name never contains a dot, so the 'v.'/'m.'/'ia.' prefixes cannot -// collide with one. Pure and headless: matchMaterial is a name lookup and -// needs no running server, and nothing here touches a TLibs or ItemsAdder -// class. -// -// The prefixes are matched case-insensitively and after trimming, since -// TLibs itself is lenient about both. Any other . prefix (nx., ...) -// is a made-up path, not a material - isUnsupportedPath tells the two apart -// so the caller can warn accordingly instead of reporting an "unknown -// material" for something that was never meant to be one. -// ==================================== public final class ItemPath { private ItemPath() { } - // Is this the m.. form at all, well formed or not? A value that - // looks like one must never fall through to matchMaterial - it would be - // reported as an unknown material rather than as a broken path. public static boolean isPluginPath(String value) { if (value == null) { return false; @@ -41,8 +15,6 @@ public static boolean isPluginPath(String value) { return v.length() >= 2 && v.regionMatches(true, 0, "m.", 0, 2); } - // The m.. path to hand to TLibs, or null when the value is not - // that form or is missing a segment public static String pluginPath(String value) { if (!isPluginPath(value)) { return null; @@ -55,8 +27,6 @@ public static String pluginPath(String value) { return v; } - // Is this the ia. form at all, well formed or not? Same rule - // as isPluginPath: it must never reach matchMaterial. public static boolean isItemsAdderPath(String value) { if (value == null) { return false; @@ -65,14 +35,6 @@ public static boolean isItemsAdderPath(String value) { return v.length() >= 3 && v.regionMatches(true, 0, "ia.", 0, 3); } - // The ':' ItemsAdder knows the item by - the same string - // /iagive takes - or null when the value is not that form or is missing a - // segment. - // - // Both 'ia.tfmc:saucepan' and 'ia.tfmc.saucepan' normalize to - // 'tfmc:saucepan': the dotted form is what an admin writes by analogy with - // m.., and rejecting it would only teach him that the plugin is - // picky about a separator ItemsAdder never sees. public static String itemsAdderId(String value) { if (!isItemsAdderPath(value)) { return null; @@ -82,13 +44,6 @@ public static String itemsAdderId(String value) { if (parts.length != 2) { return null; } - // Stripped, so 'ia.tfmc: saucepan' is the item the admin meant rather - // than an id with a space in it that only fails later, inside - // ItemsAdder; and refused outright when a segment has whitespace - // inside it, so the mistake is named at load, where the config key is - // still in the warning. Case is left exactly as written: ItemsAdder's - // matching rules are not ours to guess, and silently lower-casing an - // id would break a pack that legitimately uses a capital. String namespace = parts[0].strip(); String id = parts[1].strip(); if (namespace.isEmpty() || id.isEmpty() || hasWhitespace(namespace) || hasWhitespace(id)) { @@ -101,9 +56,6 @@ private static boolean hasWhitespace(String value) { return value.chars().anyMatch(Character::isWhitespace); } - // Does this value have a . prefix that is none of v., m. and ia.? - // Such a value was written as some other plugin's item path and must be - // reported as an unsupported path, not looked up as a material. public static boolean isUnsupportedPath(String value) { if (value == null) { return false; @@ -121,8 +73,6 @@ public static boolean isUnsupportedPath(String value) { && !prefix.equalsIgnoreCase("ia"); } - // The Material behind a bare name or the v. form, or null when - // the name is unknown, unsupported, or the value is a plugin path public static Material material(String value) { if (value == null) { return null; diff --git a/src/main/java/tfmc/justin/activity/utils/Utils.java b/src/main/java/tfmc/justin/activity/utils/Utils.java index 951cc11..1af483a 100644 --- a/src/main/java/tfmc/justin/activity/utils/Utils.java +++ b/src/main/java/tfmc/justin/activity/utils/Utils.java @@ -16,27 +16,10 @@ public static String colorize(String msg) { return ChatColor.translateAlternateColorCodes('&', msg); } - // ==================================== - // A player-supplied name on its way into the log. A name carrying a - // newline or an ANSI escape can forge log lines or repaint the console, - // so every control character is replaced before it is written. - // - // Not \p{Cntrl}, which is ASCII only: a Bedrock name can carry U+2028 or - // the right-to-left override U+202E, which break a log line just as well. - // ==================================== public static String safeForLog(String name) { return name == null ? "null" : name.replaceAll("[\\p{Cc}\\p{Cf}\\p{Zl}\\p{Zp}]", "?"); } - // ==================================== - // The same value going into an ACTIVITY-AUDIT line, where it sits beside - // other key=value pairs. safeForLog already stops a newline or an escape - // from forging a whole line, but it leaves spaces and '=' alone - and a - // Geyser name, or a configured command, carries both. Unquoted, a value - // like 'Steve result=done' lands mid-line and defeats any parser reading - // the last value of a key. A backslash or a quote inside the value is - // escaped, so the closing quote cannot be forged either. - // ==================================== public static String quotedForLog(String value) { return "\"" + safeForLog(value).replace("\\", "\\\\").replace("\"", "\\\"") + "\""; } diff --git a/src/main/java/tfmc/justin/activity/utils/Weeks.java b/src/main/java/tfmc/justin/activity/utils/Weeks.java index 9691ccf..d302e4e 100644 --- a/src/main/java/tfmc/justin/activity/utils/Weeks.java +++ b/src/main/java/tfmc/justin/activity/utils/Weeks.java @@ -5,24 +5,16 @@ import java.time.LocalDateTime; import java.time.temporal.TemporalAdjusters; -// ==================================== -// Week and day keys. Pure date maths so the rollover rules can be tested -// without a server: every entry point compares a stored key against the -// key for "now", which is cheaper and less fragile than a reset scheduler. -// ==================================== public final class Weeks { private Weeks() { } - // The date of the reset boundary the given moment falls after public static String weekKey(LocalDateTime now, DayOfWeek resetDay, int resetHour) { LocalDateTime boundary = now.toLocalDate() .with(TemporalAdjusters.previousOrSame(resetDay)) .atTime(resetHour, 0); - // Reset day but still before the reset hour - the week that is running - // is the previous one if (now.isBefore(boundary)) { boundary = boundary.minusWeeks(1); } diff --git a/src/test/java/dev/lone/itemsadder/api/CustomStack.java b/src/test/java/dev/lone/itemsadder/api/CustomStack.java index b974f2d..dc27f55 100644 --- a/src/test/java/dev/lone/itemsadder/api/CustomStack.java +++ b/src/test/java/dev/lone/itemsadder/api/CustomStack.java @@ -1,14 +1,5 @@ package dev.lone.itemsadder.api; -// ==================================== -// A test-only stand-in for ItemsAdder's real class, on the exact fully -// qualified name ItemsAdderItems.fromItemsAdder() hardcodes. Its only job is -// to make Method#invoke throw from inside the reflected call itself, so a -// real java.lang.reflect.InvocationTargetException comes out of -// fromItemsAdder() the way ItemsAdder's own getInstance()/getItemStack() can -// while a pack is mid-reload - fromItemsAdder is private, so this is the only -// way to drive that exact exception through it without ItemsAdder installed. -// ==================================== public final class CustomStack { public static int getItemStackCalls = 0; diff --git a/src/test/java/tfmc/justin/activity/commands/ActivityAdminCommandTest.java b/src/test/java/tfmc/justin/activity/commands/ActivityAdminCommandTest.java index 8817ab5..96b7e6d 100644 --- a/src/test/java/tfmc/justin/activity/commands/ActivityAdminCommandTest.java +++ b/src/test/java/tfmc/justin/activity/commands/ActivityAdminCommandTest.java @@ -34,35 +34,11 @@ import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; -// ==================================== -// The admin half of /activity driven end to end without a server: the sender -// and the target are Proxy stubs (the technique the listener tests use), and -// the one thing onCommand does that needs a live server - resolving a name to -// an OfflinePlayer - is overridden here. Everything else, including the -// permission split, the messages and the audit lines, is the real code. -// -// Not reachable headless, and so not covered: Bukkit.getPlayerExact and -// Bukkit.getOfflinePlayerIfCached inside resolve(), and the online-player list -// the completer offers as a player argument - both are overridden below. -// -// 'reload' is the fourth mutating subcommand and its audit line is not covered -// here either: onCommand runs manager.reload() before it audits, that calls -// ActivityConfiguration.load(), and its first statement is -// JavaPlugin.reloadConfig() - which, on a plugin with no data folder, throws -// IllegalArgumentException: File cannot be null out of -// YamlConfiguration.loadConfiguration, long before the audit line is reached. -// ActivityManager's constructor is private, so it cannot be subclassed to stub -// the reload out either. Its refusal path (result=denied) IS covered below. -// ==================================== class ActivityAdminCommandTest { private static final String ADMIN = "activity.admin"; private static final String CHECK = "activity.check"; - // ==================================== - // A sender that answers only what the command asks it: its permissions, - // its name, and where its replies went. - // ==================================== private static final class Sender { private final List sent = new ArrayList<>(); private final CommandSender bukkit; @@ -85,15 +61,12 @@ private Sender(String name, Set permissions) { ActivityAdminCommandTest.class.getClassLoader(), new Class[] {CommandSender.class}, handler); } - // Colour codes sit between the words a message is checked for, so - // they come off before anything is asserted about the text private String all() { return String.join("\n", sent).replaceAll("\u00a7.", ""); } } private static Sender admin() { - // What plugin.yml's child grant gives an operator return new Sender("Justin", Set.of(ADMIN, CHECK)); } @@ -115,12 +88,8 @@ private static OfflinePlayer stubTarget(UUID uuid, String name) { ActivityAdminCommandTest.class.getClassLoader(), new Class[] {OfflinePlayer.class}, handler); } - // A name the server has never seen. resolve() answers null for it, the - // way the real one does for an uncached name - without it every - // 'if (target == null) return;' guard in the handlers was dead under test. private static final String UNKNOWN = "Ghost"; - // The command with the two server-bound seams stubbed out private static ActivityCommand command(ActivityManager manager, UUID target, String targetName) { return new ActivityCommand(manager, null) { @Override @@ -141,8 +110,6 @@ List onlineNames() { } private static ActivityManager manager() { - // every: 2 with one action added, so no point is ever awarded and the - // record path stays away from Bukkit.getPlayer ActivityManager manager = TestManagers.manager( new ActivityDef("vote", "Vote", Material.PAPER, null, 2, 1, 0), new ActivityDef("quest", "Quest", Material.PAPER, null, 2, 1, 0)); @@ -171,7 +138,6 @@ private static void clean(PlayerStore store) { } } - // Everything the plugin logged at INFO carrying the audit marker private static List audit(Runnable action) { List lines = new ArrayList<>(); Handler handler = new Handler() { @@ -200,13 +166,6 @@ public void close() { return lines; } - // ==================================== - // The reply and the audit line a forced add that fills the bar leaves - // behind. --force bypasses the activity's daily cap and today's budget, - // but not bar.max - and when the bar cuts the award short the admin is - // told how much actually landed rather than being told it all went in. - // Points really are awarded here, so the Bukkit stub goes in first. - // ==================================== @Test void aForcedAddThatFillsTheWeeklyBarSaysHowMuchLanded() { TestManagers.bukkit(); @@ -228,8 +187,6 @@ void aForcedAddThatFillsTheWeeklyBarSaysHowMuchLanded() { + " activity=\"vote\" count=50 force=true points=10 result=CLAMPED_WEEKLY", lines.get(0)); } - // The same add with the bar wide open: every point lands and the reply - // says how many @Test void aForcedAddReportsThePointsItAwarded() { TestManagers.bukkit(); @@ -247,11 +204,6 @@ void aForcedAddReportsThePointsItAwarded() { assertEquals(50, manager.getStore().get(player).points()); } - // ==================================== - // An add part-way to its next award gets the plain line, with no points - // figure: "0 points." on the most common staff action reads like a - // failure when the count did go in. - // ==================================== @Test void anAddThatAwardsNoPointsYetOmitsThePointsFigure() { TestManagers.bukkit(); @@ -268,12 +220,6 @@ void anAddThatAwardsNoPointsYetOmitsThePointsFigure() { assertEquals(1, manager.getStore().get(player).count("instrument")); } - // ==================================== - // addpoints - // ==================================== - - // A manager whose bar is wide open and whose daily budget is smaller than - // the award, so a test that passes also proves bar.daily-max did not cut it private static ActivityManager pointsManager(int barMax) { TestManagers.bukkit(); ActivityManager manager = TestManagers.manager( @@ -295,8 +241,6 @@ void addpointsCreditsExactlyThePointsAsked() { assertEquals("Added 13 points to Steve.", sender.all()); PlayerData data = manager.getStore().peek(player); assertEquals(13, data.points()); - // No activity behind it: no count anywhere, and - like add --force - - // nothing charged against today's budget assertEquals(Map.of(), data.daily()); assertEquals(0, data.dailyPoints()); assertTrue(dirty(manager.getStore()), "the award was not marked for saving"); @@ -304,8 +248,6 @@ void addpointsCreditsExactlyThePointsAsked() { + " requested=13 points=13 result=RECORDED"), lines); } - // Today's budget already spent: a gated award would get nothing, this - // one still gets every point @Test void addpointsIsNotLimitedByTheDailyMax() { ActivityManager manager = pointsManager(100); @@ -337,7 +279,6 @@ void addpointsIsClampedAtTheWeeklyMaxAndSaysSo() { assertEquals(List.of("ACTIVITY-AUDIT sender=\"Justin\" action=addpoints target=\"Steve\" uuid=" + player + " requested=13 points=10 result=WEEKLY_CLAMPED"), lines); - // and a bar already full says none of it landed Sender again = admin(); command(manager, player, "Steve").onCommand( again.bukkit, null, "activity", new String[] {"addpoints", "Steve", "13"}); @@ -362,8 +303,6 @@ void addpointsRejectsAnythingButAPositiveWholeNumber() { } } - // What was typed is echoed back without its formatting codes: a value - // must not be able to recolour or restyle the reply @Test void anInvalidAddpointsValueIsEchoedWithoutFormatting() { ActivityManager manager = pointsManager(100); @@ -393,12 +332,6 @@ void addpointsWithTheWrongArgumentCountIsAUsageError() { } } - // ==================================== - // A milestone crossed by addpoints is announced exactly the way one - // crossed by add --force is: the points line, then Reward Ready, and the - // milestone is claimable afterwards. The only difference is what the - // points line names as their source. - // ==================================== @Test void addpointsCrossesAMilestoneTheSameWayAForcedAddDoes() { List byAdd = milestoneCrossedBy(100, 0, admin(), @@ -409,11 +342,6 @@ void addpointsCrossesAMilestoneTheSameWayAForcedAddDoes() { assertEquals(List.of("+12 Bonus (12/100)", "REWARD READY! Open /activity to claim."), byPoints); } - // ==================================== - // The last milestone is the weekly max itself, so an award that is - // clamped is also the one that reaches it: the clamp must not swallow - // the Reward Ready. 15 points with 10 already claimed, bar.max 20. - // ==================================== @Test void aClampedAddpointsStillReachesTheMilestoneAtTheWeeklyMax() { Sender sender = admin(); @@ -423,11 +351,6 @@ void aClampedAddpointsStillReachesTheMilestoneAtTheWeeklyMax() { assertEquals(List.of("+5 Bonus (20/20)", "REWARD READY! Open /activity to claim."), got); } - // ==================================== - // Like add --force, addpoints charges nothing against today's budget, so - // a reroll - which refunds today's earned points only - leaves it on the - // bar. - // ==================================== @Test void aRerollDoesNotTakeAddpointsBack() { ActivityManager manager = pointsManager(100); @@ -442,8 +365,6 @@ void aRerollDoesNotTakeAddpointsBack() { assertEquals(0, data.dailyPoints()); } - // The manager refuses a non-positive award itself, not just the command: - // it is public, and a row created for nothing would be pinned in the file @Test void recordPointsRefusesANonPositiveAwardWithoutCreatingARow() { ActivityManager manager = pointsManager(100); @@ -456,9 +377,6 @@ void recordPointsRefusesANonPositiveAwardWithoutCreatingARow() { assertFalse(dirty(manager.getStore())); } - // What the online target was sent when these args pushed him past the - // next unclaimed milestone. He starts on startPoints, with every - // milestone below them already claimed. private static List milestoneCrossedBy(int barMax, int startPoints, Sender sender, String[] args) { ActivityManager manager = pointsManager(barMax); UUID uuid = UUID.randomUUID(); @@ -488,16 +406,8 @@ private static List milestoneCrossedBy(int barMax, int startPoints, Send return got; } - // ==================================== - // check - // ==================================== - @Test void checkPrintsEveryFieldOfThePlayersDay() { - // daily-cap 1 makes worth(3) = 1, distinct from rawWorth(3) = 3, so - // the "before any cap" text below is checked against the uncapped - // figure the message promises rather than the capped one - which - // 'check' must read off config's own def, not merely echo the count ActivityManager manager = TestManagers.manager( new ActivityDef("vote", "Vote", Material.PAPER, null, 1, 1, 1), new ActivityDef("quest", "Quest", Material.PAPER, null, 2, 1, 0)); @@ -506,8 +416,6 @@ void checkPrintsEveryFieldOfThePlayersDay() { UUID player = UUID.randomUUID(); manager.reveal(player, manager.tasks(player).tasks().indexOf("vote")); PlayerData data = manager.getStore().peek(player); - // Straight through the POJO: crediting a point through the manager - // would announce it, and that needs a running server data.record(3, new ActivityDef("vote", "Vote", Material.PAPER, null, 1, 1, 1), 50, 10, List.of(10, 20)); Sender sender = admin(); @@ -515,33 +423,21 @@ void checkPrintsEveryFieldOfThePlayersDay() { String out = sender.all(); assertTrue(out.contains("Steve"), out); - // weekly points / bar max / claimed assertTrue(out.contains(data.points() + "/50"), out); assertTrue(out.contains("0 already claimed"), out); - // daily points / daily max assertTrue(out.contains(data.dailyPoints() + "/10"), out); - // rerolls used / allowance - with the label, since "0/1" on its own is - // a prefix of the daily line's "0/10" and passes with the line deleted assertTrue(out.contains("Rerolls used today: 0/1"), out); assertTrue(out.contains(data.weekKey()), out); assertTrue(out.contains(data.dayKey()), out); - // the one revealed task, with its count and what it is worth today assertTrue(out.contains("Vote"), out); assertTrue(out.contains("revealed"), out); assertTrue(out.contains("count 3"), out); assertTrue(out.contains("that count is worth 3 points before any cap"), out); - // and every slot of the draw is listed assertEquals(data.tasks().size(), sender.sent.stream().filter(line -> line.contains("revealed") || line.contains("hidden")).count()); assertTrue(out.contains("hidden"), out); } - // ==================================== - // Config display names carry colour/format codes (hex, '&', or both - // stacked, as here) so the GUI can paint them - but check() prints to - // console/chat with no item lore to carry that colour, so it must strip - // every code the config accepts before printing the name. - // ==================================== @Test void checkStripsColourCodesFromTheTaskName() { ActivityManager manager = TestManagers.manager( @@ -586,13 +482,6 @@ void checkMutatesNothing() { assertFalse(dirty(manager.getStore()), "check marked the store dirty"); } - // ==================================== - // A row whose stored day is not today - what every offline player's row - // becomes, since nothing rolls them. check() must neither roll it nor - // print its numbers as "today": swap peek() for get() in handleCheck and - // this fails, because the row rolls forward and the draw, the counts and - // the reroll all go with it. - // ==================================== @Test void checkOnAStaleRowRollsNothingAndReportsThePostRolloverDay() { ActivityManager manager = manager(); @@ -600,7 +489,6 @@ void checkOnAStaleRowRollsNothingAndReportsThePostRolloverDay() { manager.tasks(player); PlayerData data = manager.getStore().peek(player); String week = data.weekKey(); - // Back-date the day the way a rollover would have moved it on data.roll(week, "1999-01-01"); data.reroll(List.of("vote"), 50); data.record(3, new ActivityDef("vote", "Vote", Material.PAPER, null, 2, 1, 0), 50, 10, List.of(10, 20)); @@ -610,7 +498,6 @@ void checkOnAStaleRowRollsNothingAndReportsThePostRolloverDay() { Sender sender = admin(); command(manager, player, "Steve").onCommand(sender.bukkit, null, "activity", new String[] {"check", "Steve"}); - // nothing was rolled, replaced or marked for saving assertSame(data, manager.getStore().peek(player)); assertEquals("1999-01-01", data.dayKey()); assertEquals(List.of("vote"), data.tasks()); @@ -618,19 +505,15 @@ void checkOnAStaleRowRollsNothingAndReportsThePostRolloverDay() { assertEquals(3, data.count("vote")); assertFalse(dirty(manager.getStore()), "check marked the store dirty"); - // and the day is reported as their next login will leave it String out = sender.all(); assertTrue(out.contains("Stale: stored week " + week + ", day 1999-01-01"), out); assertTrue(out.contains("Today: 0/10 points"), out); assertFalse(out.contains("Today: 1/10 points"), out); assertTrue(out.contains("Rerolls used today: 0/1"), out); assertTrue(out.contains("No tasks drawn yet today"), out); - // the week is still the current one, so the weekly bar survives it assertTrue(out.contains("Weekly: 1/50 points"), out); } - // Today's daily-reward flag, and a stale day's read as its next login - // leaves it: not claimed @Test void checkShowsTodaysDailyRewardAndIgnoresAStaleDaysFlag() { ActivityManager manager = manager(); @@ -657,8 +540,6 @@ void checkShowsTodaysDailyRewardAndIgnoresAStaleDaysFlag() { assertFalse(dirty(manager.getStore()), "check marked the store dirty"); } - // peek() runs none of the clamping rolled() does, so a bar.max lowered - // under a stored total has to be clamped on the way out instead @Test void checkClampsPointsAndClaimedToALoweredBar() { ActivityManager manager = manager(); @@ -677,9 +558,6 @@ void checkClampsPointsAndClaimedToALoweredBar() { assertEquals(40, data.claimedPoints(), "check clamped the stored value"); } - // Same as above but for the daily figure: peek() clamps neither, so a - // daily.max lowered under a stored total prints stale numbers on "Today" - // until the row is next touched @Test void checkClampsDailyPointsToALoweredDailyMax() { ActivityManager manager = manager(); @@ -720,10 +598,6 @@ void checkWithoutAPlayerIsAUsageError() { assertTrue(sender.all().contains("Usage:"), sender.all()); } - // ==================================== - // the permission split - // ==================================== - @Test void onlyCheckAnswersToTheCheckPermission() { assertEquals(CHECK, ActivityCommand.permissionFor("check")); @@ -777,11 +651,8 @@ void everyMutatingSubcommandIsRefusedToACheckOnlySender() { .onCommand(sender.bukkit, null, "activity", args)); assertTrue(sender.all().contains("No permission"), args[0] + ": " + sender.all()); - // A refused mutating attempt leaves a line of its own: probing for - // what one may run must not be silent assertEquals(List.of("ACTIVITY-AUDIT sender=\"Mod\" action=" + args[0] + " result=denied"), lines, args[0] + ": the refusal was not audited"); - // nothing was touched assertEquals(1, manager.getStore().peek(player).rerolls(), args[0]); assertEquals(0, manager.getStore().peek(player).count("vote"), args[0]); assertEquals(0, manager.getStore().peek(player).points(), args[0]); @@ -789,10 +660,6 @@ void everyMutatingSubcommandIsRefusedToACheckOnlySender() { } } - // ==================================== - // givereroll - // ==================================== - @Test void rerollGivesOneBackAndChangesNothingElse() { ActivityManager manager = manager(); @@ -811,7 +678,6 @@ void rerollGivesOneBackAndChangesNothingElse() { assertEquals(0, data.rerolls()); assertTrue(sender.all().contains("from 1 to 0"), sender.all()); assertTrue(dirty(manager.getStore()), "the give-back was not marked for saving"); - // the draw, the points and the reveals are untouched assertEquals(tasks, data.tasks()); assertEquals(points, data.points()); assertEquals(0, data.dailyPoints()); @@ -847,11 +713,6 @@ void rerollOnAPlayerWithNoDataIsANoOpAndCreatesNoRow() { assertNull(manager.getStore().peek(player), "reroll created a row"); } - // ==================================== - // A stale day is a day the player has not started: no reroll of it has - // been used, and the give-back must say so without rolling the row over - // as a side effect - which is what 'check' reports for the same row. - // ==================================== @Test void givererollOnAStaleRowRefusesAndRollsNothing() { ActivityManager manager = manager(); @@ -869,7 +730,6 @@ void givererollOnAStaleRowRefusesAndRollsNothing() { assertTrue(sender.all().contains("no rerolls today"), sender.all()); assertEquals(List.of("ACTIVITY-AUDIT sender=\"Justin\" action=givereroll target=\"Steve\" uuid=" + player + " rerolls=0->0 result=noop"), lines); - // the stale day is exactly as it was: no rollover happened assertEquals("1999-01-01", data.dayKey()); assertEquals(1, data.rerolls()); assertEquals(List.of("vote"), data.tasks()); @@ -886,15 +746,6 @@ void rerollWithoutAPlayerIsAUsageError() { assertTrue(sender.all().contains("Usage:"), sender.all()); } - // ==================================== - // the unknown player - // ==================================== - - // ==================================== - // resolve() answering null is the guard every handler opens with. Each - // one has to stop there, touch nothing, and - for the mutating ones - - // leave a line saying a name that does not exist was asked about. - // ==================================== @Test void anUnknownPlayerStopsEveryHandlerAndIsAuditedUnlessItIsCheck() { for (String[] args : List.of( @@ -920,7 +771,6 @@ void anUnknownPlayerStopsEveryHandlerAndIsAuditedUnlessItIsCheck() { : List.of("ACTIVITY-AUDIT sender=\"Justin\" action=" + args[0] + " typed=\"" + UNKNOWN + "\" result=unknown-player"), lines, args[0]); - // and no handler got past the guard assertEquals(1, manager.getStore().peek(player).rerolls(), args[0]); assertEquals(0, manager.getStore().peek(player).count("vote"), args[0]); assertEquals(List.of("vote"), manager.getStore().peek(player).tasks(), args[0]); @@ -928,10 +778,6 @@ void anUnknownPlayerStopsEveryHandlerAndIsAuditedUnlessItIsCheck() { } } - // ==================================== - // audit logging - // ==================================== - @Test void everyMutatingSubcommandLogsExactlyOneAuditLine() { ActivityManager manager = manager(); @@ -952,8 +798,6 @@ void everyMutatingSubcommandLogsExactlyOneAuditLine() { assertEquals("ACTIVITY-AUDIT sender=\"Justin\" action=add target=\"Steve\" uuid=" + player + " activity=\"vote\" count=1 force=true points=0 result=ADDED", add.get(0)); - // the give-back that found nothing to give back is logged too, and - // says so - a mutating command that changed nothing is still a line List noop = audit(() -> command.onCommand(sender.bukkit, null, "activity", new String[] {"givereroll", "Steve"})); assertEquals(1, noop.size(), String.valueOf(noop)); @@ -968,12 +812,6 @@ void everyMutatingSubcommandLogsExactlyOneAuditLine() { + " rerolls=1->0 result=done", reroll.get(0)); } - // ==================================== - // The shape a Geyser name, or a command block a player renamed, can - // reach the log with: a control character, spaces, and something that - // reads as another key=value pair. The escape is replaced and the whole - // value is quoted, so neither can pass for part of the line. - // ==================================== @Test void aHostileNameIsSanitisedAndQuotedInTheAuditLine() { ActivityManager manager = manager(); @@ -988,13 +826,6 @@ void aHostileNameIsSanitisedAndQuotedInTheAuditLine() { + "target=\"Steve result=done\" uuid=" + player + " result=done", lines.get(0)); } - // ==================================== - // A name carrying a bare '"' would close the field early and let - // whatever follows it be read as fresh key=value pairs; a bare '\' - // just before the closing quote would escape it instead of ending the - // string. quoted() must escape both. Deleting either .replace(...) call - // in quoted() must fail this test. - // ==================================== @Test void aQuoteAndABackslashInANameAreEscapedInTheAuditLine() { ActivityManager manager = manager(); @@ -1021,19 +852,12 @@ void checkLogsNothing() { assertEquals(List.of(), audit(() -> command(manager, UUID.randomUUID(), "Nobody") .onCommand(sender.bukkit, null, "activity", new String[] {"check", "Nobody"}))); - // including when it is the one being refused Sender nobody = new Sender("Player", Set.of()); assertEquals(List.of(), audit(() -> command(manager, player, "Steve") .onCommand(nobody.bukkit, null, "activity", new String[] {"check", "Steve"}))); assertTrue(nobody.all().contains("No permission"), nobody.all()); } - // ==================================== - // An unrecognised subcommand used to be mapped to activity.admin and - // refused, which hid the usage line from the check-only sender who had - // just mistyped one. It is a usage error for anyone who may run anything, - // and still nothing at all for a sender who may not. - // ==================================== @Test void anUnknownSubcommandIsAUsageErrorForAnyoneAllowedIn() { ActivityManager manager = manager(); @@ -1052,8 +876,6 @@ void anUnknownSubcommandIsAUsageErrorForAnyoneAllowedIn() { assertEquals(List.of(), lines, "an unknown subcommand was audited"); } - // A console sender has no Player behind it, and its name still has to - // reach the log line @Test void theConsoleIsNamedInTheAuditLine() { ActivityManager manager = manager(); @@ -1067,10 +889,6 @@ void theConsoleIsNamedInTheAuditLine() { assertTrue(lines.get(0).startsWith("ACTIVITY-AUDIT sender=\"CONSOLE\" action=reset"), lines.get(0)); } - // ==================================== - // tab completion - // ==================================== - @Test void aCheckOnlySenderIsOfferedCheckAndNothingElse() { ActivityManager manager = manager(); @@ -1080,12 +898,10 @@ void aCheckOnlySenderIsOfferedCheckAndNothingElse() { command.onTabComplete(checkOnly().bukkit, null, "activity", new String[] {""})); assertEquals(List.of(), command.onTabComplete(checkOnly().bukkit, null, "activity", new String[] {"re"})); - // ...and cannot complete a mutating subcommand's arguments either assertEquals(List.of(), command.onTabComplete(checkOnly().bukkit, null, "activity", new String[] {"reset", ""})); assertEquals(List.of(), command.onTabComplete(checkOnly().bukkit, null, "activity", new String[] {"add", "Steve", ""})); - // but does get a player for 'check' assertEquals(List.of("Steve"), command.onTabComplete(checkOnly().bukkit, null, "activity", new String[] {"check", "Ste"})); } @@ -1114,10 +930,6 @@ void aSenderWithNeitherPermissionIsOfferedNothing() { .onTabComplete(nobody.bukkit, null, "activity", new String[] {""})); } - // ==================================== - // the shipped text - // ==================================== - @Test void everyNewAdminMessageIsShipped() { YamlConfiguration messages = YamlConfiguration @@ -1140,8 +952,6 @@ void everyNewAdminMessageIsShipped() { .getString("commands.activity.usage", ""); assertTrue(pluginUsage.contains("check"), pluginUsage); assertTrue(pluginUsage.contains("givereroll"), pluginUsage); - // Its own key, so a live messages.yml that predates it still gets the - // line - and not also in admin.usage, or a fresh install shows it twice String addpointsUsage = messages.getString("admin.usage-addpoints", ""); assertTrue(addpointsUsage.contains("addpoints "), addpointsUsage); assertFalse(usage.contains("addpoints"), usage); diff --git a/src/test/java/tfmc/justin/activity/commands/ActivityCommandTest.java b/src/test/java/tfmc/justin/activity/commands/ActivityCommandTest.java index d0d07e6..3bf9658 100644 --- a/src/test/java/tfmc/justin/activity/commands/ActivityCommandTest.java +++ b/src/test/java/tfmc/justin/activity/commands/ActivityCommandTest.java @@ -24,13 +24,6 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; -// ==================================== -// /activity add is the console intake path (ConditionalEvents and friends), so -// it goes through the same daily-task gate a listener does; only an explicit -// trailing --force skips it. onCommand itself needs a live server to resolve a -// player, so what is checked here is the one argument that decides which of -// the two record paths add takes, plus that the usage strings say so. -// ==================================== class ActivityCommandTest { private static String[] add(String... trailing) { @@ -49,8 +42,6 @@ void addIsGatedUnlessForceIsAskedFor() { assertTrue(ActivityCommand.forced(add("--force"))); } - // The flag is exact: the one argument that decides whether the gate runs - // is not left to a case-insensitive guess at what was meant @Test void onlyTheExactFlagCountsAsForce() { assertFalse(ActivityCommand.forced(add("--FORCE"))); @@ -59,7 +50,6 @@ void onlyTheExactFlagCountsAsForce() { assertFalse(ActivityCommand.forced(add("-f"))); assertFalse(ActivityCommand.forced(add(""))); - // ...and a miscased flag is a usage error, not a quiet gated add assertFalse(ActivityCommand.wellFormedAdd(add("--FORCE"))); } @@ -90,13 +80,6 @@ void nothingButOneTrailingForceIsAWellFormedAdd() { assertFalse(ActivityCommand.wellFormedAdd(new String[] {"add", "Justin", "vote"})); } - // ==================================== - // The add routing, on a real manager built without a server (see - // TestManagers). Every activity is 'every: 2' and one action is added, so - // no point is ever awarded and the record path stays away from - // Bukkit.getPlayer(), unreachable headless. The witness for "credited" is - // the stored action count. - // ==================================== private static ActivityCommand command(ActivityManager manager) { return new ActivityCommand(manager, null); } @@ -111,7 +94,6 @@ void aGatedAddToATaskThatIsNotRevealedCreditsNothingAndSaysSo() { UUID player = UUID.randomUUID(); assertEquals(Outcome.NOT_A_TASK, command(manager).add(player, "vote", 5, false).outcome()); - // Not even a row: a gated add must not pin an offline player in the file assertNull(manager.getStore().peek(player)); } @@ -153,12 +135,6 @@ void anActivityNobodyLoadedIsUnknownEitherWay() { assertEquals(Outcome.UNKNOWN_ACTIVITY, command(manager).add(player, "nope", 1, true).outcome()); } - // ==================================== - // A cap that swallows the points is not a success: the count goes in, no - // point reaches the bar, and the admin is told which cap did it. Set up - // by lowering the limit, since awarding a point headlessly would reach - // Bukkit.getPlayer. - // ==================================== @Test void aSpentDailyBudgetIsReportedRatherThanCountedAsAdded() { ActivityManager manager = TestManagers.manager( @@ -168,7 +144,6 @@ void aSpentDailyBudgetIsReportedRatherThanCountedAsAdded() { manager.reveal(player, manager.tasks(player).tasks().indexOf("vote")); assertEquals(Outcome.CAPPED_DAILY, command(manager).add(player, "vote", 5, false).outcome()); - // The count still went in - only the points were lost assertEquals(5, manager.getStore().get(player).count("vote")); assertEquals(0, manager.getStore().get(player).points()); } @@ -189,23 +164,15 @@ void aFullWeeklyBarIsReportedRatherThanCountedAsAdded() { void anActivityThatHasGivenAllItCanTodayIsReported() { ActivityManager manager = TestManagers.manager( new ActivityDef("vote", "Vote", Material.PAPER, null, 1, 1, 1)); - // A full bar keeps the point off it, so the cap can be reached headless TestManagers.limits(manager, 0, 10); UUID player = UUID.randomUUID(); manager.reveal(player, manager.tasks(player).tasks().indexOf("vote")); ActivityCommand command = command(manager); - // The first add is what meets the activity's own daily cap of 1 assertEquals(Outcome.CAPPED_WEEKLY, command.add(player, "vote", 1, false).outcome()); assertEquals(Outcome.CAPPED_ACTIVITY, command.add(player, "vote", 1, false).outcome()); } - // ==================================== - // What --force is for: the shipped vote activity (every: 1, points: 1, - // daily-cap: 5) with bar.daily-max 10, credited 50. Both limits are - // ignored and all 50 points land. Awarding a point reaches - // Bukkit.getPlayer, so the server stub goes in first. - // ==================================== @Test void forceAwardsTheFullWorthPastTheActivityCapAndTheDailyBudget() { TestManagers.bukkit(); @@ -223,8 +190,6 @@ void forceAwardsTheFullWorthPastTheActivityCapAndTheDailyBudget() { assertEquals(0, manager.getStore().get(player).dailyPoints()); } - // The same add unforced: the activity cap of 5 is what lands, and the - // daily budget of 10 would have held it to 10 anyway @Test void theSameAddUnforcedIsStillHeldToTheActivityCap() { TestManagers.bukkit(); @@ -242,8 +207,6 @@ void theSameAddUnforcedIsStillHeldToTheActivityCap() { assertEquals(5, manager.getStore().get(player).dailyPoints()); } - // bar.max is the one limit --force does not bypass: a bar that cannot hold - // the award is reported as clamped rather than as a plain success @Test void forceStillStopsAtTheWeeklyMaximum() { TestManagers.bukkit(); @@ -258,13 +221,11 @@ void forceStillStopsAtTheWeeklyMaximum() { assertEquals(10, added.points()); assertEquals(10, manager.getStore().get(player).points()); - // ...and a second forced add onto the full bar has nothing to clamp ActivityCommand.Added again = command(manager).add(player, "vote", 5, true); assertEquals(Outcome.CAPPED_WEEKLY, again.outcome()); assertEquals(0, again.points()); } - // A count part-way to its next point is a plain success, not a cap @Test void partialProgressTowardsTheNextPointIsStillAdded() { ActivityManager manager = manager(); @@ -273,8 +234,6 @@ void partialProgressTowardsTheNextPointIsStillAdded() { assertEquals(Outcome.ADDED, command(manager).add(player, "vote", 1, true).outcome()); } - // Each outcome has to have something to say, and it has to be in the - // shipped file - a missing key reaches the admin as a raw path @Test void everyOutcomeHasAShippedMessage() { YamlConfiguration messages = YamlConfiguration @@ -294,9 +253,6 @@ void everyOutcomeHasAShippedMessage() { assertFalse(value == null || value.isBlank(), outcome.messageKey() + " is missing"); } - // The points figure rides on its own key, not on admin.add-done: that - // key predates the figure, so a live messages.yml would win over the - // packaged default and hide it String plain = messages.getString("admin.add-done"); assertFalse(plain == null || plain.contains("%points%"), plain); String withPoints = messages.getString("admin.add-done-points"); @@ -308,12 +264,6 @@ void everyOutcomeHasAShippedMessage() { assertTrue(notATask.contains("--force"), notATask); } - // ==================================== - // /activity asks for the daily reward on open, which is how a failed one - // is retried. build() needs a live server, so the GUI hands back no - // window; the reward is an m. path with no TLibs behind it, so the - // attempt shows as a refusal in chat - the proof the open asked for it. - // ==================================== @Test void openingTheMenuAsksForTheDailyReward() { ActivityManager manager = TestManagers.manager( diff --git a/src/test/java/tfmc/justin/activity/config/ActivityConfigurationClickCommandsTest.java b/src/test/java/tfmc/justin/activity/config/ActivityConfigurationClickCommandsTest.java index 9b78334..e513628 100644 --- a/src/test/java/tfmc/justin/activity/config/ActivityConfigurationClickCommandsTest.java +++ b/src/test/java/tfmc/justin/activity/config/ActivityConfigurationClickCommandsTest.java @@ -15,13 +15,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; -// ==================================== -// The 'click-commands:' activity key and the 'click-command-cooldown-millis' -// rate limit that guards it. load() needs a live Bukkit server, so the two -// parsing methods are driven directly through reflection - the same trick, and -// the same hand-built JavaPlugin, as ActivityConfigurationGuaranteedTest. The -// plugin's logger is captured so the warnings can be asserted on. -// ==================================== class ActivityConfigurationClickCommandsTest { private final List logged = new ArrayList<>(); @@ -68,8 +61,6 @@ void anActivityWithoutTheKeyCarriesAnEmptyList() { assertEquals(List.of(), only(config).clickCommands()); } - // A single 'click-commands: sudo %player% votelist' is the natural typo, - // and it must not be swallowed the way a malformed list once was here @Test void aNonListValueWarnsAndYieldsAnEmptyList() { ActivityConfiguration config = configFor(" vote:\n points: 1\n" @@ -87,7 +78,6 @@ void blankEntriesAreDropped() { assertEquals(List.of("say hi"), only(config).clickCommands()); } - // The one numeric parser all three click-command knobs go through private int clamped(String yaml, String path, int fallback, int min, int max) { ActivityConfiguration config = new ActivityConfiguration(stubPlugin()); try { @@ -126,8 +116,6 @@ void aCooldownInsideTheBoundsIsKept() { assertEquals(2500, cooldown(ActivityConfiguration.CLICK_COMMAND_COOLDOWN_PATH + ": 2500\n")); } - // There is no "off" value: 0 would let a held mouse button dispatch - // console commands at click rate @Test void aCooldownBelowTheFloorIsClampedUp() { assertEquals(50, cooldown(ActivityConfiguration.CLICK_COMMAND_COOLDOWN_PATH + ": 0\n")); @@ -146,9 +134,6 @@ void aNonNumericCooldownWarnsAndFallsBackToOneSecond() { assertTrue(warned("is not a number"), logged.toString()); } - // The shipped file, against the exact paths the parser reads - a typo in - // either would otherwise go unnoticed, since the shipped cooldown is also - // the fallback @Test void theShippedConfigWiresVotingUpAndSetsTheCooldown() { YamlConfiguration shipped = @@ -161,12 +146,6 @@ void theShippedConfigWiresVotingUpAndSetsTheCooldown() { assertEquals(5, shipped.getInt(ActivityConfiguration.CLICK_COMMANDS_PER_CLICK_PATH)); } - // ==================================== - // The server-wide ceiling and the per-click cap, which the per-player - // cooldown cannot give: they are parsed by the same method, so what is - // pinned here is that each one is read from its own path with its own - // default and its own bounds. - // ==================================== @Test void anAbsentGlobalLimitIsTwentyASecond() { assertEquals(20, perSecond("bar:\n max: 50\n")); @@ -177,8 +156,6 @@ void aGlobalLimitInsideTheBoundsIsKept() { assertEquals(60, perSecond(ActivityConfiguration.CLICK_COMMANDS_PER_SECOND_PATH + ": 60\n")); } - // No "off" value here either: 0 a second would stop the feature dead - // rather than bound it, which is what removing 'click-commands' is for @Test void aGlobalLimitOutsideTheBoundsIsClamped() { assertEquals(1, perSecond(ActivityConfiguration.CLICK_COMMANDS_PER_SECOND_PATH + ": 0\n")); diff --git a/src/test/java/tfmc/justin/activity/config/ActivityConfigurationDescriptionTest.java b/src/test/java/tfmc/justin/activity/config/ActivityConfigurationDescriptionTest.java index f81939b..929c196 100644 --- a/src/test/java/tfmc/justin/activity/config/ActivityConfigurationDescriptionTest.java +++ b/src/test/java/tfmc/justin/activity/config/ActivityConfigurationDescriptionTest.java @@ -14,13 +14,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; -// ==================================== -// The optional 'description:' activity key, which takes either a single string -// or a list of lines. load() needs a live Bukkit server, so loadActivities is -// driven directly through reflection - the same trick, and the same hand-built -// JavaPlugin, as ActivityConfigurationClickCommandsTest. The plugin's logger is -// captured so the warning can be asserted on. -// ==================================== class ActivityConfigurationDescriptionTest { private final List logged = new ArrayList<>(); @@ -76,8 +69,6 @@ void anActivityWithoutTheKeyCarriesAnEmptyDescription() { assertEquals(List.of(), only(config).description()); } - // Neither a string nor a list: named rather than silently dropped, the - // same bug the click-commands and reward-item parsers refuse to have @Test void aValueThatIsNeitherStringNorListWarnsAndYieldsEmpty() { ActivityConfiguration config = configFor(" vote:\n points: 1\n description:\n" diff --git a/src/test/java/tfmc/justin/activity/config/ActivityConfigurationGuaranteedTest.java b/src/test/java/tfmc/justin/activity/config/ActivityConfigurationGuaranteedTest.java index 7a860a7..3cb31d5 100644 --- a/src/test/java/tfmc/justin/activity/config/ActivityConfigurationGuaranteedTest.java +++ b/src/test/java/tfmc/justin/activity/config/ActivityConfigurationGuaranteedTest.java @@ -15,13 +15,6 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; -// ==================================== -// The 'daily-guaranteed:' activity key, parsed by the private loadActivities() -// and read back through guaranteed(). load() itself needs a live Bukkit server, -// so the parsing method is driven directly - the same trick, and the same -// hand-built JavaPlugin, as ActivityConfigurationStationTest. The plugin's -// logger is captured so the warnings can be asserted on. -// ==================================== class ActivityConfigurationGuaranteedTest { private final List logged = new ArrayList<>(); @@ -72,8 +65,6 @@ void guaranteedIdsKeepConfigOrder() { assertEquals(List.of("vote", "battle"), config.guaranteed()); } - // A typo is named and the activity is left not guaranteed, the same way a - // non-number 'every' falls back @Test void aGarbageValueIsReportedAndFallsBackToFalse() { ActivityConfiguration config = configFor(entry("vote", "\"yes please\"")); @@ -83,7 +74,6 @@ void aGarbageValueIsReportedAndFallsBackToFalse() { && line.contains("not true or false")), logged.toString()); } - // An activity dropped for being worth nothing never reaches the flag @Test void anActivityDroppedForZeroPointsIsNotGuaranteed() { ActivityConfiguration config = configFor(" vote:\n points: 0\n daily-guaranteed: true\n"); diff --git a/src/test/java/tfmc/justin/activity/config/ActivityConfigurationIconPathTest.java b/src/test/java/tfmc/justin/activity/config/ActivityConfigurationIconPathTest.java index b4d15f2..0c99a52 100644 --- a/src/test/java/tfmc/justin/activity/config/ActivityConfigurationIconPathTest.java +++ b/src/test/java/tfmc/justin/activity/config/ActivityConfigurationIconPathTest.java @@ -16,20 +16,6 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; -// ==================================== -// What an activity's 'material:' key becomes: the Material the GUI falls back -// to, and the path the GUI asks a plugin for. Reached through the private -// loadActivities() the way ActivityConfigurationStationTest reaches it - -// load() itself needs a live Bukkit server. -// -// Not reachable headless, and so not covered here: ActivityGui#iconStack -// itself, because every branch of it ends in new ItemStack(Material), which -// goes through the item registry of a running server. What is pinned instead -// is the pair iconStack is handed - an ia. path plus the Material to fall back -// to when nothing can be built from it - and, in ItemsAdderItemsTest, that the -// hook does answer null for an id that cannot be resolved. The two together -// are the fallback. -// ==================================== class ActivityConfigurationIconPathTest { private final List logged = new ArrayList<>(); @@ -53,29 +39,22 @@ private boolean loggedContains(String fragment) { return logged.stream().anyMatch(message -> message.contains(fragment)); } - // The live server's own report: 'ia.tfmc:saucepan' used to be called an - // unsupported path and lose its icon @Test void anItemsAdderIconIsKeptAsAPathWithPaperBehindIt() { ActivityDef def = load("ia.tfmc:saucepan"); assertEquals("ia.tfmc:saucepan", def.iconPath()); - // what iconStack falls back to when ItemsAdder is absent or the id no - // longer resolves assertEquals(Material.PAPER, def.icon()); assertFalse(loggedContains("Unsupported item path")); assertFalse(loggedContains("Unknown material")); } - // The dotted spelling is the same item, and only one form reaches the hook @Test void theDottedIconFormIsNormalizedToTheColonForm() { assertEquals("ia.tfmc:saucepan", load("ia.tfmc.saucepan").iconPath()); assertEquals("ia.tfmc:saucepan", load("IA.tfmc.saucepan").iconPath()); } - // Nothing to ask ItemsAdder for, so no path is kept - the icon is PAPER - // and the admin is told which key to fix @Test void aMalformedItemsAdderIconFallsBackToPaperAndIsNamed() { ActivityDef def = load("ia.saucepan"); @@ -86,8 +65,6 @@ void aMalformedItemsAdderIconFallsBackToPaperAndIsNamed() { assertTrue(loggedContains("expected ia.")); } - // Still reported as a path rather than as an unknown material, and the - // warning now names every form that would have worked @Test void anotherPluginsPrefixIsStillUnsupportedAndSaysWhatIsSupported() { ActivityDef def = load("nx.saucepan"); @@ -98,17 +75,12 @@ void anotherPluginsPrefixIsStillUnsupportedAndSaysWhatIsSupported() { assertTrue(loggedContains("ia.")); } - // Whitespace around a segment is the admin's spacing, not part of the id: - // ItemsAdder is whitespace-sensitive, so 'tfmc: saucepan' would resolve to - // nothing at runtime while passing load clean @Test void spacingAroundAnItemsAdderSegmentIsStripped() { assertEquals("ia.tfmc:saucepan", load("ia.tfmc: saucepan").iconPath()); assertEquals("ia.tfmc:saucepan", load("ia. tfmc . saucepan ").iconPath()); } - // Whitespace inside a segment cannot be spacing, so it is named at load - // rather than failing silently per claim @Test void whitespaceInsideAnItemsAdderSegmentIsRefusedAtLoad() { ActivityDef def = load("ia.tfmc:sauce pan"); @@ -118,17 +90,11 @@ void whitespaceInsideAnItemsAdderSegmentIsRefusedAtLoad() { assertTrue(loggedContains("Malformed item path 'ia.tfmc:sauce pan' at activities.cook_dish.material")); } - // Case is left exactly as the admin wrote it - ItemsAdder's matching rules - // are not guessed at here @Test void theCaseOfAnItemsAdderIdIsNotTouched() { assertEquals("ia.TFMC:Saucepan", load("ia.TFMC:Saucepan").iconPath()); } - // The m. and bare forms are untouched by any of this. An m. path drops its - // path here rather than at GUI-build time - itemPathsUsable is false with - // no server behind this test - which is exactly what it did before; the - // PAPER behind it is the part that matters. @Test void theOlderFormsStillLoadTheWayTheyDid() { assertNull(load("m.material.steel").iconPath()); diff --git a/src/test/java/tfmc/justin/activity/config/ActivityConfigurationRewardsTest.java b/src/test/java/tfmc/justin/activity/config/ActivityConfigurationRewardsTest.java index 9837d53..9061a29 100644 --- a/src/test/java/tfmc/justin/activity/config/ActivityConfigurationRewardsTest.java +++ b/src/test/java/tfmc/justin/activity/config/ActivityConfigurationRewardsTest.java @@ -29,13 +29,6 @@ import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; -// ==================================== -// rewards.pool and bar.milestones parsing: private instance methods -// (loadRewardPool, loadMilestones) reached the same way -// ActivityConfigurationStationTest reaches loadActivities/loadStation - -// load() itself needs a live Bukkit server, so a bypass-allocated JavaPlugin -// with a real Logger stands in and the private parser is invoked directly. -// ==================================== class ActivityConfigurationRewardsTest { private static final class TestPlugin extends JavaPlugin { @@ -58,9 +51,6 @@ private static JavaPlugin stubPlugin() { } } - // What the parser logged while this test ran: the warnings are half of - // what these keys do, and a warning that names the wrong index is worth no - // more than none private final List logged = new ArrayList<>(); private final Handler capture = new Handler() { @@ -130,10 +120,6 @@ private static List loadMilestones(int barMax, List raw) { } } - // ==================================== - // rewards.pool - // ==================================== - @Test void aZeroWeightEntryIsSkipped() { List pool = loadRewardPool( @@ -181,8 +167,6 @@ void anEntryWithAnEmptyCommandsListIsSkipped() { @Test void aNonMapListItemIsSkipped() { - // getMapList() itself drops non-map entries before loadRewardPool ever - // sees them - only the map entry survives List pool = loadRewardPool( "rewards:\n pool:\n - just a string\n - weight: 4\n display: 'Real entry'\n" + " commands: ['say hi']\n"); @@ -206,11 +190,6 @@ void weightIsClampedSoTheCumulativeTotalCannotOverflow() { assertEquals(1_000_000, pool.get(0).weight()); } - // ==================================== - // rewards.pool[N].items - an entry may hand items over instead of, or as - // well as, running commands - // ==================================== - @Test void anItemOnlyEntryLoadsWithItsPathAndAmount() { List pool = loadRewardPool("rewards:\n pool:\n - weight: 2\n display: 'Steel'\n" @@ -261,13 +240,6 @@ void aMissingAmountDefaultsToOneAndTheBoundsAreClamped() { new RewardEntry.Item("DIAMOND", 1)), pool.get(0).items()); } - // ==================================== - // An unknown material, another plugin's path syntax and a malformed m. - // path can never resolve, so they are dropped at load rather than kept to - // fail at payout. A well formed m. path is kept: nothing here can tell a - // live MMOItems id from a deleted one, and TLibs may not even be enabled - // while this runs. - // ==================================== @Test void unusableItemPathsAreDroppedAndTheRestOfTheEntrySurvives() { List pool = loadRewardPool("rewards:\n pool:\n - weight: 1\n items:\n" @@ -281,8 +253,6 @@ void unusableItemPathsAreDroppedAndTheRestOfTheEntrySurvives() { assertEquals(1, pool.size()); assertEquals(List.of(new RewardEntry.Item("m.material.steel", 1)), pool.get(0).items()); - // Each warning must name the element it came from, or an admin cannot - // find the line to fix assertTrue(loggedContains("Unknown material 'NOT_A_MATERIAL' at rewards.pool[0].items[0]")); assertTrue(loggedContains("Unsupported item path 'nx.custom.block' at rewards.pool[0].items[1]")); assertTrue(loggedContains("Malformed item path 'm.material' at rewards.pool[0].items[2]")); @@ -290,13 +260,6 @@ void unusableItemPathsAreDroppedAndTheRestOfTheEntrySurvives() { assertTrue(loggedContains("rewards.pool[0].items[4] is not an 'item:'/'amount:' block")); } - // ==================================== - // ia. is a reward item like any other: kept at load in the - // form the admin wrote, normalized only when the payout asks ItemsAdder. - // Nothing here can tell a live ItemsAdder id from a deleted one, and - // ItemsAdder may not even be enabled while this runs - so both separators - // survive the parser. - // ==================================== @Test void anItemsAdderPathIsKeptAsARewardItem() { List pool = loadRewardPool("rewards:\n pool:\n - weight: 1\n items:\n" @@ -311,8 +274,6 @@ void anItemsAdderPathIsKeptAsARewardItem() { assertFalse(loggedContains("Malformed item path")); } - // A path with no id behind it can never resolve, so it is dropped at load - // rather than kept to hand over nothing at payout @Test void aMalformedItemsAdderPathIsDroppedAndNamed() { List pool = loadRewardPool("rewards:\n pool:\n - weight: 1\n items:\n" @@ -324,11 +285,6 @@ void aMalformedItemsAdderPathIsDroppedAndNamed() { assertTrue(loggedContains("expected ia.")); } - // ==================================== - // The one line the whole file gets when it asks for ItemsAdder items and - // ItemsAdder is not there. load() itself needs a live server, so the rule - // behind it is pinned directly. - // ==================================== @Test void theItemsAdderWarningIsOnlyGivenWhenItIsBothAskedForAndMissing() { assertNull(ActivityConfiguration.itemsAdderWarning(false, false)); @@ -338,15 +294,9 @@ void theItemsAdderWarningIsOnlyGivenWhenItIsBothAskedForAndMissing() { String warning = ActivityConfiguration.itemsAdderWarning(true, false); assertNotNull(warning); assertTrue(warning.contains("ItemsAdder is not enabled"), warning); - // an admin must be told the payout consequence, not just the fact assertTrue(warning.contains("leaves its milestone unclaimed"), warning); } - // ==================================== - // 'items:' written as anything but a list - 'items: DIAMOND', or the - // single-item map that is the natural typo - used to load clean and pay - // less than the admin wrote - // ==================================== @Test void anItemsKeyThatIsNotAListIsNamedRatherThanSwallowed() { List pool = loadRewardPool("rewards:\n pool:\n - weight: 1\n" @@ -372,12 +322,6 @@ void anEntryWithNoItemsKeyAtAllSaysNothing() { assertFalse(loggedContains("items is not a list")); } - // ==================================== - // An amount is range-tested as a long before it is narrowed: intValue() on - // 4294967298 is 2, which would pass the test having asked for something - // else entirely. A fractional amount is a different mistake and falls back - // rather than rounding in silence. - // ==================================== @Test void anAmountTooLargeForAnIntIsClampedRatherThanNarrowedIntoRange() { List pool = loadRewardPool("rewards:\n pool:\n - weight: 1\n items:\n" @@ -402,15 +346,6 @@ void anEntryWhoseOnlyItemPathsAreUnusableIsSkipped() { + " - item: 'NOT_A_MATERIAL'\n").isEmpty()); } - // ==================================== - // rewards.multiplier - the number of pool spins per milestone, and the - // amount multiplier for fixed drops and the daily reward. Clamped the way every other numeric key here is; 0 or negative - // reads as 1, never as "hand nothing over". - // ==================================== - - // Fed a whole config rather than a value, so the key path itself is under - // test: the shipped multiplier is 1 and so is the fallback, which means a - // typo in the path is invisible everywhere else. private int rewardMultiplier(String yamlContent) { try { ActivityConfiguration config = new ActivityConfiguration(stubPlugin()); @@ -447,8 +382,6 @@ void aMultiplierAboveTheUpperBoundIsClamped() { assertTrue(loggedContains("rewards.multiplier 1000 is outside 1-64 - using 64.")); } - // The wiring, not the clamp: read out of a whole config at the path the - // shipped file writes, so a typo in either would be caught here @Test void theMultiplierIsReadFromItsConfiguredPath() { assertEquals(3, rewardMultiplier("rewards:\n multiplier: 3\n pool: []\n")); @@ -461,26 +394,18 @@ void anAbsentMultiplierReadsAsOneWithoutAWarning() { assertFalse(loggedContains("rewards.multiplier")); } - // getInt would have truncated this to 2 in silence, paying double what was - // written rather than what a broken key is meant to pay @Test void aFractionalMultiplierFallsBackToOneWithAWarning() { assertEquals(1, rewardMultiplier("rewards:\n multiplier: 2.9\n")); assertTrue(loggedContains("rewards.multiplier '2.9' is not a whole number - using 1.")); } - // getInt reported this as "rewards.multiplier 0 is outside 1-64", naming a - // value the admin never wrote @Test void aNonNumericMultiplierFallsBackToOneAndIsNamedAsWritten() { assertEquals(1, rewardMultiplier("rewards:\n multiplier: 'three'\n")); assertTrue(loggedContains("rewards.multiplier is not a number ('three') - using 1.")); } - // ==================================== - // bar.milestones - // ==================================== - @Test void unsortedAndDuplicatedMilestonesAreSortedAndDeduped() { assertEquals(List.of(10, 20, 30), loadMilestones(50, List.of(30, 10, 20, 10, 30))); @@ -501,8 +426,6 @@ void barMaxFifteenClipsTheDefaultToTenOnly() { assertEquals(List.of(10), loadMilestones(15, List.of())); } - // A bar too small for either hardcoded default still gets one reward, at - // bar.max itself @Test void aBarSmallerThanBothDefaultsFallsBackToBarMaxItself() { assertEquals(List.of(5), loadMilestones(5, List.of())); @@ -519,10 +442,6 @@ void nullAndZeroAndNegativeMilestoneValuesAreDropped() { assertEquals(List.of(10), loadMilestones(50, raw)); } - // ==================================== - // rewards.drops - // ==================================== - private static ActivityConfiguration withMilestones(List milestones) { try { ActivityConfiguration config = new ActivityConfiguration(stubPlugin()); @@ -562,8 +481,6 @@ private static boolean flag(ActivityConfiguration config, String name) { } } - // Keyed by the milestone's points, not by N: drop_2 of [10, 20, 30] is 20. - // The display is the bare name; claim() prefixes the paid amount. @Test void aFixedDropLoadsAgainstItsMilestoneWithPathAmountAndName() { Map drops = loadDrops(List.of(10, 20, 30), @@ -624,9 +541,6 @@ void aMalformedAmountFallsBackToThePoolWithAWarning() { assertTrue(loggedContains("milestone 50 draws from the pool")); } - // Unknown to Bukkit is a typo and falls back, and the log says so; a well - // formed m. or ia. path cannot be checked without its plugin, so it is - // kept for payout to decide @Test void anUnknownMaterialFallsBackButPluginPathsAreKeptUnchecked() { Map drops = loadDrops(List.of(10, 20, 30), @@ -639,8 +553,6 @@ void anUnknownMaterialFallsBackButPluginPathsAreKeptUnchecked() { assertTrue(loggedContains("rewards.drops.drop_1 has no usable item path - milestone 10 draws from the pool.")); } - // A drops-only m. or ia. path must count as "asked for", or load() says - // nothing when TLibs or ItemsAdder is missing @Test void aDropsOnlyPluginPathTriggersTheMissingPluginWarnings() { ActivityConfiguration config = withMilestones(List.of(10, 20)); @@ -660,10 +572,6 @@ void theItemPathWarningIsOnlyGivenWhenItIsBothAskedForAndMissing() { .contains("but TLibs, MMOItems are not enabled")); } - // ==================================== - // The load-time "nothing can ever be claimed" warning - // ==================================== - private static final RewardEntry FIXED = new RewardEntry(1, "Diamond", List.of(), List.of(new RewardEntry.Item("DIAMOND", 1))); @@ -684,10 +592,6 @@ void anEmptyPoolWithNoDropsIsWarnedAboutAsBefore() { assertFalse(ActivityConfiguration.poolNeededButEmpty(List.of(FIXED), Map.of(), List.of(10, 20))); } - // ==================================== - // daily-reward.groups - // ==================================== - private static Map loadDailyRewards(ActivityConfiguration config, String yamlContent) { try { Method method = ActivityConfiguration.class.getDeclaredMethod("loadDailyRewards", @@ -705,7 +609,6 @@ private static Map loadDailyRewards(String yamlContent) { return loadDailyRewards(new ActivityConfiguration(stubPlugin()), yamlContent); } - // Config order is kept: it is what decides which group wins @Test void dailyRewardGroupsLoadInConfigOrderWithPathAmountAndName() { Map groups = loadDailyRewards( @@ -745,8 +648,6 @@ void aBadDailyRewardIsDroppedWithAWarningAndTheRestKept() { assertTrue(loggedContains("daily-reward.groups.d 'DIAMOND 2 3' is not")); } - // 'pool' mixes with the item forms, in config order; with a pool to draw - // from nothing is logged @Test void aPoolDailyRewardGroupLoadsBesideAnItemGroup() throws ReflectiveOperationException { ActivityConfiguration config = new ActivityConfiguration(stubPlugin()); @@ -779,7 +680,6 @@ void anEmptyPoolIsNotWarnedAboutWithoutAPoolGroup() { assertTrue(logged.isEmpty(), logged.toString()); } - // Kept like a drop's, and counted towards the one missing-plugin warning @Test void aDailyRewardOnAPluginPathIsKeptAndFlagged() { ActivityConfiguration config = new ActivityConfiguration(stubPlugin()); @@ -791,11 +691,6 @@ void aDailyRewardOnAPluginPathIsKeptAndFlagged() { assertTrue(flag(config, "itemsAdderPathConfigured")); } - // ==================================== - // The block form: ': {item: , amount: }' in addition to the - // one-line string form, for both rewards.drops and daily-reward.groups. - // ==================================== - @Test void aBlockDropLoadsWithItsPathAndAmount() { Map drops = loadDrops(List.of(10, 20), @@ -849,10 +744,6 @@ void aBlockDropWithABadAmountFallsBackToThePoolWithAWarning() { assertTrue(loggedContains("rewards.drops.drop_3.amount 'x' is not 1-64")); } - // A fractional or boolean amount never matches the digit check any more - // than a non-numeric word does, so it takes the same "is not 1-64" path - // as amount: 0 or amount: 65 - not a separate "not a whole number" path - // the way the one-line string form's sibling key (rewards.multiplier) has @Test void aBlockDropWithAFractionalAmountFallsBackToThePoolWithAWarning() { Map drops = loadDrops(List.of(10, 20), @@ -873,8 +764,6 @@ void aBlockDropWithABooleanAmountFallsBackToThePoolWithAWarning() { assertTrue(loggedContains("milestone 10 draws from the pool")); } - // An empty block has no 'item:' key at all, same as the block that only - // sets 'amount:' - block.getString("item") returns null either way @Test void anEmptyBlockDropFallsBackToThePoolWithAWarning() { Map drops = loadDrops(List.of(10, 20), diff --git a/src/test/java/tfmc/justin/activity/config/ActivityConfigurationStationTest.java b/src/test/java/tfmc/justin/activity/config/ActivityConfigurationStationTest.java index 75aadfc..f5bfe98 100644 --- a/src/test/java/tfmc/justin/activity/config/ActivityConfigurationStationTest.java +++ b/src/test/java/tfmc/justin/activity/config/ActivityConfigurationStationTest.java @@ -17,30 +17,8 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; -// ==================================== -// The 'station:' activity key, loaded by the private loadActivities() / -// loadStation() the same way 'craft:' and 'profession:' are, and read back -// through the public stationActivity(stationId, recipeId). -// -// load() itself needs a live Bukkit server (reloadConfig(), Messages, -// Bukkit.getPluginManager()), which is why every other test in this class -// drives the private parsing methods directly instead. loadStation() is not -// static like registerCraft()/professionActivity() though - it logs through -// plugin.getLogger(), so a JavaPlugin is still needed. JavaPlugin's own -// constructor throws unless its classloader is a live PluginClassLoader, so -// one is built the same way DishCookedListenerTest builds its event: bypass -// the constructor via ReflectionFactory, then hand it a real -// java.util.logging.Logger so the warnings do not NPE. -// -// YamlConfiguration.loadConfiguration(Reader) is pure SnakeYAML and needs no -// server either - see DefaultResourcesTest. -// ==================================== class ActivityConfigurationStationTest { - // JavaPlugin itself is abstract, and ReflectionFactory's bypass allocator - // still rejects an abstract class outright (InstantiationError) even - // without running a constructor - so a trivial concrete subclass, itself - // never constructed normally either, is what actually gets allocated. private static final class TestPlugin extends JavaPlugin { } @@ -76,9 +54,6 @@ private static void loadActivities(ActivityConfiguration config, ConfigurationSe } } - // Builds a config from an 'activities:' YAML block. Every entry needs - // 'points' to survive loadActivities()'s earlier checks and actually - // reach loadStation(). private static ActivityConfiguration configFor(String activitiesYaml) { ActivityConfiguration config = new ActivityConfiguration(stubPlugin()); loadActivities(config, section("activities:\n" + activitiesYaml, "activities")); @@ -131,8 +106,6 @@ void unknownStationIsEmpty() { assertEquals(Optional.empty(), config.stationActivity("", "anything")); } - // Documented behaviour: two activities claiming one station key, last one - // loaded wins - same rule as a duplicate profession @Test void duplicateStationClaimKeepsTheLastActivity() { ActivityConfiguration config = configFor( @@ -141,11 +114,6 @@ void duplicateStationClaimKeepsTheLastActivity() { assertEquals(Optional.of("second"), config.stationActivity("forge", "anything")); } - // station: alongside craft: (or profession:) is a conflict: only the - // earlier-declared feed survives, and station is ignored entirely. - // craft: uses an m.. item path rather than a bare Material name - // so registerCraft never has to ask a live server's item registry whether - // the material is craftable - see registerCraft's own ItemPath branch. @Test void stationTogetherWithCraftIsIgnored() { ActivityConfiguration config = configFor( @@ -162,9 +130,6 @@ void stationTogetherWithProfessionIsIgnored() { assertEquals(Optional.empty(), config.stationActivity("forge", "anything")); } - // Malformed values (leading slash, trailing slash, blank) must not throw - // and must register nothing - so no station/recipe pair can ever match - // the activity they were meant for @Test void malformedValuesRegisterNothingAndDoNotThrow() { ActivityConfiguration config = assertDoesNotThrowConfig( @@ -176,9 +141,6 @@ void malformedValuesRegisterNothingAndDoNotThrow() { assertEquals(Optional.empty(), config.stationActivity("ingot-station", "")); } - // Multiple slashes in a station: value are rejected as malformed and - // register nothing - so no split can ever match the activity they were - // meant for. @Test void multipleSlashesRegisterNothingAndDoNotThrow() { ActivityConfiguration config = assertDoesNotThrowConfig( @@ -194,8 +156,6 @@ private static ActivityConfiguration assertDoesNotThrowConfig(String activitiesY return org.junit.jupiter.api.Assertions.assertDoesNotThrow(() -> configFor(activitiesYaml)); } - // A reload must not leave a station claimed by a config that no longer - // asks for it @Test void reloadReplacesTheStationMapEntirely() { ActivityConfiguration config = configFor(entry("smelt", "forge")); diff --git a/src/test/java/tfmc/justin/activity/config/ActivityConfigurationTest.java b/src/test/java/tfmc/justin/activity/config/ActivityConfigurationTest.java index 57fe904..aabedb2 100644 --- a/src/test/java/tfmc/justin/activity/config/ActivityConfigurationTest.java +++ b/src/test/java/tfmc/justin/activity/config/ActivityConfigurationTest.java @@ -17,18 +17,6 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; -// ==================================== -// The two optional activity keys that load() parses out of config.yml. -// -// craft: Material#isAir and #isItem both go through the item registry, which -// does not exist headless, so the live server's answer is handed in as a -// predicate - exactly as ActivityConfiguration hands in its own. AIR and -// BEDROCK stand for the two things a live server rejects. -// -// profession: the id normalization and the lookup that uses it. load() itself -// needs a running server, so the map is built here the same way loadActivities -// builds it - through normalizeProfessionId. -// ==================================== class ActivityConfigurationTest { private static final Predicate CRAFTABLE = @@ -80,12 +68,6 @@ void aMalformedItemPathIsReportedAndRegistersNothing() { assertTrue(crafts.isEmpty() && paths.isEmpty()); } - // ==================================== - // A craft key is the one place an ia. path is not accepted: matching a - // crafted ItemStack back to an ItemsAdder id is the opposite lookup and is - // not implemented. It must say so - registering nothing quietly would give - // an activity that can never be fed and no hint why. - // ==================================== @Test void anItemsAdderCraftKeyIsRefusedInItsOwnWords() { Map crafts = new HashMap<>(); @@ -96,7 +78,6 @@ void anItemsAdderCraftKeyIsRefusedInItsOwnWords() { assertTrue(problem != null && problem.contains("ItemsAdder item path"), problem); assertTrue(problem.contains(path), problem); assertTrue(problem.contains("activities.cook_dish.craft"), problem); - // not the generic "unsupported path" line: ia. does work elsewhere assertFalse(problem.contains("Unsupported item path"), problem); assertTrue(problem.contains("nothing will ever feed that activity"), problem); } @@ -142,7 +123,6 @@ void noCraftKeyRegistersNothing() { assertTrue(crafts.isEmpty()); } - // A typo must not start feeding some other activity, and must say so @Test void anUnknownNameIsRejectedAndNamed() { Map crafts = new HashMap<>(); @@ -154,9 +134,6 @@ void anUnknownNameIsRejectedAndNamed() { assertTrue(crafts.isEmpty()); } - // AIR is what the special recipes (firework rockets, banner copies, map - // extending) report as their result, so accepting it would hand one - // activity every one of them @Test void airIsRejected() { Map crafts = new HashMap<>(); @@ -178,8 +155,6 @@ void aBlockOnlyNameIsRejected() { assertTrue(crafts.isEmpty()); } - // One material feeds one activity: the second claim is unreachable, so it - // loses and gets said out loud @Test void aDuplicateMaterialKeepsTheFirstActivity() { Map crafts = new HashMap<>(); @@ -192,8 +167,6 @@ void aDuplicateMaterialKeepsTheFirstActivity() { assertTrue(problem.contains("only 'craft_anvil' will be credited"), problem); } - // The raw config string reaches a log line, so a name carrying a newline - // or an escape must not be able to forge one @Test void theRawNameIsSanitisedBeforeItReachesTheLog() { Map crafts = new HashMap<>(); @@ -206,7 +179,6 @@ void theRawNameIsSanitisedBeforeItReachesTheLog() { @Test void normalizationMatchesMmoCore() { - // MMOCore: id = rawId.toLowerCase().replace("_", "-").replace(" ", "-") assertEquals("mining-expert", ActivityConfiguration.normalizeProfessionId("mining_expert")); assertEquals("mining-expert", ActivityConfiguration.normalizeProfessionId("Mining Expert")); assertEquals("mining-expert", ActivityConfiguration.normalizeProfessionId("MINING-EXPERT")); @@ -219,8 +191,6 @@ void normalizationIsIdempotent() { assertEquals(once, ActivityConfiguration.normalizeProfessionId(once)); } - // The whole point of MEDIUM 1: a config file written the way config.yml - // tells admins to write it must still find MMOCore's dashed id @Test void underscoredConfigIdMatchesDashedMmoCoreId() { Map professions = professions("mining_expert"); @@ -245,7 +215,6 @@ void unknownBlankAndNullProfessionsAreNotTracked() { assertNull(ActivityConfiguration.professionActivity(Map.of(), "crafter")); } - // Documented behaviour: two activities on one profession, last one wins @Test void duplicateProfessionKeepsTheLastActivity() { Map professions = new LinkedHashMap<>(); @@ -257,11 +226,6 @@ void duplicateProfessionKeepsTheLastActivity() { assertEquals("second", ActivityConfiguration.professionActivity(professions, "crafter")); } - // ==================================== - // The bar-unreachable ceiling. A player only ever earns from the seven - // tasks drawn for them, so the day is bounded by the seven lowest - // daily-caps rather than by every activity's cap added up. - // ==================================== private static List caps(int count, int cap) { List caps = new ArrayList<>(); for (int i = 0; i < count; i++) { @@ -272,8 +236,6 @@ private static List caps(int count, int cap) { @Test void theDailyCeilingIsTheSevenLowestCapsNotAllOfThem() { - // 40 activities at daily-cap 1 with bar.daily-max 10: only seven can - // be drawn, so seven is the day's ceiling - not 10, and not 40 assertEquals(7, ActivityConfiguration.dailyCeiling(caps(40, 1), 40, 10)); } @@ -288,25 +250,16 @@ void theDailyCeilingNeverExceedsDailyMax() { assertEquals(10, ActivityConfiguration.dailyCeiling(caps(40, 5), 40, 10)); } - // An uncapped activity is in every draw once there are too few capped - // ones to fill one, and then only bar.daily-max bounds the day @Test void tooFewCappedActivitiesFallsBackToDailyMax() { assertEquals(10, ActivityConfiguration.dailyCeiling(caps(6, 1), 40, 10)); } - // Fewer loaded activities than a draw holds: the draw is all of them, so - // their caps still bound the day @Test void fewerActivitiesThanADrawAreStillBoundedByTheirCaps() { assertEquals(3, ActivityConfiguration.dailyCeiling(caps(3, 1), 3, 10)); } - // ==================================== - // What the warning says about the ceiling it found. Both bounds have to - // be named when the caps land exactly on bar.daily-max: lifting either - // one alone changes nothing. - // ==================================== @Test void capsBelowDailyMaxAreNamedAsTheBound() { String warning = ActivityConfiguration.unreachableWarning(caps(40, 1), 40, 10, 100); @@ -325,8 +278,6 @@ void capsThatLandExactlyOnDailyMaxNameBoth() { assertTrue(warning.contains("bound by per-activity daily-caps and bar.daily-max)"), warning); } - // The draw is every loaded activity when fewer than seven are loaded, and - // the warning must not claim seven of them @Test void theWarningCountsTheDrawItNotSeven() { assertTrue(ActivityConfiguration.unreachableWarning(caps(3, 1), 3, 10, 100) @@ -340,11 +291,6 @@ void aReachableFirstRewardWarnsAboutNothing() { assertNull(ActivityConfiguration.unreachableWarning(caps(40, 5), 40, 10, 10)); } - // ==================================== - // The two reroll knobs. load() never runs headless and both getInt - // fallbacks equal the shipped values, so only a non-default value proves - // the path string is the one an admin edits. - // ==================================== private static YamlConfiguration yaml(String path, Object value) { YamlConfiguration config = new YamlConfiguration(); config.set(path, value); diff --git a/src/test/java/tfmc/justin/activity/config/ActivityConfigurationVoteShareTest.java b/src/test/java/tfmc/justin/activity/config/ActivityConfigurationVoteShareTest.java index 4b48064..6629a8b 100644 --- a/src/test/java/tfmc/justin/activity/config/ActivityConfigurationVoteShareTest.java +++ b/src/test/java/tfmc/justin/activity/config/ActivityConfigurationVoteShareTest.java @@ -10,11 +10,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; -// ==================================== -// bar.vote-share: the clamp on the value read and the non-vote share of -// bar.daily-max it leaves. load() needs a live server, so the parser and the -// arithmetic are driven directly, with the plugin's logger captured. -// ==================================== class ActivityConfigurationVoteShareTest { private final List logged = new ArrayList<>(); diff --git a/src/test/java/tfmc/justin/activity/config/DefaultResourcesTest.java b/src/test/java/tfmc/justin/activity/config/DefaultResourcesTest.java index 87754d5..4479647 100644 --- a/src/test/java/tfmc/justin/activity/config/DefaultResourcesTest.java +++ b/src/test/java/tfmc/justin/activity/config/DefaultResourcesTest.java @@ -16,15 +16,6 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; -// ==================================== -// The shipped default messages.yml/config.yml now mix inline #rrggbb hex -// with legacy &-codes (see the diff that introduced fillEmptySlots and the -// hex accents). YamlConfiguration.loadConfiguration works headless (pure -// SnakeYAML under the hood, no live server needed) - these tests just load -// the real resources exactly as the plugin ships them and check the hex -// strings survived as values, not as swallowed YAML comments, and that every -// placeholder callers substitute into is still present. -// ==================================== class DefaultResourcesTest { private static YamlConfiguration load(String resource) { @@ -116,8 +107,6 @@ void everyActivityDisplayIsNonEmpty() { } } - // A material that does not resolve leaves the activity with no GUI icon, - // and YAML alone will not catch a typo in one @Test void everyActivityMaterialResolves() { YamlConfiguration config = load("config.yml"); @@ -134,9 +123,6 @@ void everyActivityMaterialResolves() { } } - // The shipped milestones must parse as a list of ints: written inline with - // a trailing comment, a typo here reads as an empty list and silently - // falls back to the hardcoded defaults. @Test void theShippedMilestonesParseAsNumbers() { YamlConfiguration config = load("config.yml"); @@ -144,8 +130,6 @@ void theShippedMilestonesParseAsNumbers() { assertEquals(List.of(10, 20), config.getIntegerList("bar.milestones")); } - // Every shipped pool entry must be drawable: a weight above 0, something - // to say in chat and at least one item to hand over. @Test void everyRewardPoolEntryIsUsable() { YamlConfiguration config = load("config.yml"); @@ -164,16 +148,9 @@ void everyRewardPoolEntryIsUsable() { assertFalse(display == null || String.valueOf(display).isBlank(), where + ".display should not be blank"); - // The shipped pool pays in items rather than commands on purpose: - // a console 'give' that fails is invisible here, so the player is - // marked paid having received nothing assertTrue(entry.get("items") instanceof List items && !items.isEmpty(), where + ".items should list at least one item"); - // The payload itself, not just its shape: the four paths and their - // amounts are the whole shipped economy, and both are checkable - // headless (ItemPath.material is a name lookup). A 'DIAMONDS' typo - // would otherwise ship green and pay nothing. List items = (List) entry.get("items"); for (int j = 0; j < items.size(); j++) { String at = where + ".items[" + j + "]"; @@ -182,9 +159,6 @@ void everyRewardPoolEntryIsUsable() { Object path = item.get("item"); assertFalse(path == null || String.valueOf(path).isBlank(), at + ".item should not be blank"); - // Every shipped entry pays a bare Material on purpose: an - // m.. would make the default config depend on - // MMOItems being installed assertTrue(tfmc.justin.activity.utils.ItemPath.material(String.valueOf(path)) != null, at + ".item is not a Material: " + path); @@ -195,10 +169,6 @@ void everyRewardPoolEntryIsUsable() { } } - // The shipped multiplier: the number of pool spins, and the amount - // multiplier for fixed drops and the daily reward. 1 is also the fallback, - // so this reads the key through the constant load() reads it through - a - // typo in either end fails here rather than reading as 1 in silence. @Test void shippedConfigDefaultsTheRewardMultiplierToOne() { YamlConfiguration config = load("config.yml"); @@ -208,11 +178,6 @@ void shippedConfigDefaultsTheRewardMultiplierToOne() { assertEquals(1, config.getInt(ActivityConfiguration.REWARDS_MULTIPLIER_PATH)); } - // Every "gui." literal ActivityGui.java passes to Messages.get(...) - // must resolve against the shipped messages.yml - read straight from the - // source file rather than hand-copied, so a new lookup added there without - // a matching messages.yml entry fails this test instead of NPEing at - // runtime in front of a player. @Test void everyGuiMessageKeyActivityGuiReadsExistsInMessagesYml() throws java.io.IOException { File source = new File("src/main/java/tfmc/justin/activity/gui/ActivityGui.java"); @@ -234,8 +199,6 @@ void everyGuiMessageKeyActivityGuiReadsExistsInMessagesYml() throws java.io.IOEx } } - // The daily draw picks from every loaded activity, so the shipped file - // has to offer at least the seven a player is handed each day @Test void shippedConfigHasEnoughActivitiesForADailyDraw() { YamlConfiguration config = load("config.yml"); @@ -262,7 +225,6 @@ void shippedMessagesHasTheDailyBarName() { assertFalse(value == null || value.isBlank(), "gui.daily-bar-name should not be blank"); } - // The default budget the reroll button ships with: 1 a day, not off @Test void shippedConfigDefaultsRerollsPerDayToOne() { YamlConfiguration config = load("config.yml"); @@ -270,7 +232,6 @@ void shippedConfigDefaultsRerollsPerDayToOne() { assertEquals(1, config.getInt(ActivityConfiguration.REROLLS_PER_DAY_PATH)); } - // The default point gate: rerollable at 0 or 1 points earned today @Test void shippedConfigDefaultsRerollMaxPointsToOne() { YamlConfiguration config = load("config.yml"); @@ -295,8 +256,6 @@ void shippedMessagesHasEveryRerollKeyNonBlank() { } } - // The lore that shows the remaining budget needs both placeholders to be - // worth anything - a fixed string would lie once the budget changed @Test void theRerollLoreLeftKeepsItsPlaceholders() { YamlConfiguration messages = load("messages.yml"); @@ -306,9 +265,6 @@ void theRerollLoreLeftKeepsItsPlaceholders() { assertTrue(value.contains("%max%")); } - // The shipped descriptions, read through the exact key the parser reads - - // a typo in either end would leave the examples invisible in silence. Both - // accepted shapes ship: cook_dish is a plain string, vote is a list. @Test void theShippedConfigDescribesVoteAndCookDish() { YamlConfiguration config = load("config.yml"); @@ -324,8 +280,6 @@ void theShippedConfigDescribesVoteAndCookDish() { } } - // Every description that ships, whatever shape it is written in, has to be - // non-blank and use valid hex markers - the same bar display is held to @Test void everyShippedDescriptionIsNonBlankAndValidHex() { YamlConfiguration config = load("config.yml"); diff --git a/src/test/java/tfmc/justin/activity/config/TestPlugins.java b/src/test/java/tfmc/justin/activity/config/TestPlugins.java index 7fc3281..b886578 100644 --- a/src/test/java/tfmc/justin/activity/config/TestPlugins.java +++ b/src/test/java/tfmc/justin/activity/config/TestPlugins.java @@ -11,16 +11,6 @@ import java.util.logging.LogRecord; import java.util.logging.Logger; -// ==================================== -// The hand-built JavaPlugin every ActivityConfiguration test needs, in one -// place rather than copied per class. JavaPlugin's constructor throws unless -// its classloader is a live PluginClassLoader, so one is allocated through -// ReflectionFactory and handed a logger of its own - an anonymous one, so two -// test classes running in the same JVM never see each other's lines. -// -// Everything a config parser warns about goes to that logger, which is why it -// is captured: the warnings are half of what these tests assert. -// ==================================== final class TestPlugins { private TestPlugins() { @@ -29,7 +19,6 @@ private TestPlugins() { private static final class TestPlugin extends JavaPlugin { } - // The plugin, logging every message it emits into 'logged' static JavaPlugin capturing(List logged) { try { ReflectionFactory rf = ReflectionFactory.getReflectionFactory(); diff --git a/src/test/java/tfmc/justin/activity/config/Tier1HooksTest.java b/src/test/java/tfmc/justin/activity/config/Tier1HooksTest.java index ec837f5..7bf586c 100644 --- a/src/test/java/tfmc/justin/activity/config/Tier1HooksTest.java +++ b/src/test/java/tfmc/justin/activity/config/Tier1HooksTest.java @@ -19,17 +19,6 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; -// ==================================== -// Five new tier-1 hooks (VFBuilders, RPCharacters, InteractibleFurniture, -// SimpleFactions, AdvancedCrafting) each map a foreign event -// to manager.recordAction(uuid, "", amount). The listener classes -// themselves need a live server / foreign event instances and are not -// unit-tested here. These tests check the parts that are pure data and can -// be loaded headlessly: the config.yml entries the listeners rely on, the -// plugin.yml softdepend declarations, and that every literal activity id a -// listener passes to recordAction actually exists in config.yml (a typo -// there would silently drop the points at runtime). -// ==================================== class Tier1HooksTest { private static final List NEW_ACTIVITY_IDS = List.of( @@ -38,12 +27,6 @@ class Tier1HooksTest { "casino_win", "cook_dish", "animal_universal_feed" ); - // ==================================== - // The MMOItems station activities are fed through - // ActivityConfiguration.stationActivity(), so the listener never names - // them as literals - they are checked for sane config, not for a - // recordAction literal. - // ==================================== private static final List STATION_ACTIVITY_IDS = List.of( "ingot_flint", "ingot_coal", "tool_iron_pickaxe", "tool_iron_axe", @@ -85,9 +68,6 @@ void everyNewActivityHasSaneNumericBounds() { } } - // Each station activity must carry the 'station:' key that feeds it, and - // no two may claim the same station/recipe - a duplicate silently drops - // one of them at load @Test void everyStationActivityDeclaresAUniqueStationRecipe() { YamlConfiguration config = loadConfig(); @@ -141,8 +121,6 @@ void everyListenerRecordedActivityIdExistsInConfig() throws IOException { } } - // Sanity: the five new ids should actually have been found via the - // literal-string scan, otherwise this test would trivially pass. assertTrue(recordedIds.containsAll(NEW_ACTIVITY_IDS), "expected to find all new ids recorded by listeners, found: " + recordedIds); diff --git a/src/test/java/tfmc/justin/activity/gui/ActivityGuiTest.java b/src/test/java/tfmc/justin/activity/gui/ActivityGuiTest.java index a8fe932..e9f0828 100644 --- a/src/test/java/tfmc/justin/activity/gui/ActivityGuiTest.java +++ b/src/test/java/tfmc/justin/activity/gui/ActivityGuiTest.java @@ -28,12 +28,6 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; -// ==================================== -// ActivityGui.build() needs a live Bukkit server (Bukkit.createInventory) and -// is not reachable here. What is checked instead is the slot geometry every -// build() call relies on: TASK_SLOTS, SIZE etc are private, read through -// reflection rather than widened just for this test. -// ==================================== class ActivityGuiTest { private static int[] taskSlots() throws ReflectiveOperationException { @@ -69,8 +63,6 @@ void dailyBarAndWeeklyBarSlotsAreFixed() throws ReflectiveOperationException { assertEquals(5, slot("BAR_SLOT")); } - // Between the two bars, so a player is never in doubt which button - // affects which of them @Test void theRerollSlotIsBetweenTheTwoBars() throws ReflectiveOperationException { assertEquals(4, slot("REROLL_SLOT")); @@ -89,7 +81,6 @@ void theTaskSlotsAvoidTheBarsAndStayInsideTheWindow() throws ReflectiveOperation "TASK_SLOTS lists the same slot twice"); } - // The reroll slot does not double as a bar slot either @Test void theRerollSlotIsNotAlsoABarSlot() throws ReflectiveOperationException { assertTrue(slot("REROLL_SLOT") != slot("DAILY_BAR_SLOT")); @@ -104,20 +95,12 @@ private static ActivityDef activity(String id, int every, int points, int dailyC return new ActivityDef(id, id, Material.PAPER, null, every, points, dailyCap); } - // ==================================== - // progressBar shows progress toward the NEXT point (count % every out of - // every), not the whole daily budget - it wraps every time a point is - // earned rather than filling once across the whole day. - // ==================================== @Test void progressBarIsPartialMidCycle() { - // market_sale-like: every 25, cap 2. 22 of 25 into the next point. ActivityDef def = activity("a", 25, 1, 2); assertEquals(Bar.render(22, 25, 20), ActivityGui.progressBar(def, 22)); } - // count == every means a point was just earned and the next cycle starts - // fresh at zero, not a full bar. @Test void progressBarIsEmptyExactlyAtAPointBoundary() { ActivityDef def = activity("a", 25, 1, 2); @@ -130,35 +113,24 @@ void progressBarIsFullOnceTheDailyCapIsReached() { assertEquals(Bar.render(1, 1, 20), ActivityGui.progressBar(def, 50)); } - // Past the cap (e.g. a stray extra count) must still read as full, not - // reset or overflow. @Test void progressBarStaysFullPastTheDailyCap() { ActivityDef def = activity("a", 25, 1, 2); assertEquals(Bar.render(1, 1, 20), ActivityGui.progressBar(def, 73)); } - // every == 1 uncapped: each count is already a whole point, so there is - // no meaningful partial progress to show. @Test void progressBarIsHiddenForEveryOneUncapped() { ActivityDef def = activity("a", 1, 1, 0); assertEquals(null, ActivityGui.progressBar(def, 3)); } - // every == 1 capped still shows full once the cap is hit, same as any - // other capped activity. @Test void progressBarIsFullForEveryOneCapped() { ActivityDef def = activity("a", 1, 1, 2); assertEquals(Bar.render(1, 1, 20), ActivityGui.progressBar(def, 2)); } - // ==================================== - // Click routing. onClick itself needs a live InventoryClickEvent, so what - // is checked here is the one decision it makes about a raw slot: which - // task, if any, the slot stands for. - // ==================================== @Test void theTaskRowRoutesToTaskZeroThroughSix() { for (int raw = 10; raw <= 16; raw++) { @@ -174,16 +146,9 @@ void everyOtherSlotRoutesToNothing() { for (int raw = 17; raw <= 26; raw++) { assertEquals(-1, ActivityGui.taskSlot(raw), "raw slot " + raw); } - // A click outside any inventory assertEquals(-1, ActivityGui.taskSlot(-999)); } - // ==================================== - // What each of the seven task slots paints, which is what both build() - // and the repaint after a reveal click walk. A slot with no task in it - // has to come back null, so the caller can put filler there instead of - // leaving whatever was in the slot before. - // ==================================== private static ActivityManager threeLoaded() { return TestManagers.manager( new ActivityDef("a", "A", Material.PAPER, null, 1, 1, 0), @@ -216,7 +181,6 @@ void aSlotPastTheEndOfTheDrawPaintsFiller() { assertNull(ActivityGui.taskIdAt(config, data, -1)); } - // A reload that dropped an activity leaves its id in a draw read off disk @Test void aSlotHoldingAnActivityNobodyLoadedPaintsFiller() { ActivityManager manager = threeLoaded(); @@ -228,28 +192,18 @@ void aSlotHoldingAnActivityNobodyLoadedPaintsFiller() { assertEquals("c", ActivityGui.taskIdAt(manager.getConfiguration(), data, 2)); } - // The two named controls: the daily bar is display-only, and filler is - // never a task @Test void theDailyBarAndFillerRouteToNoTask() throws ReflectiveOperationException { assertEquals(-1, ActivityGui.taskSlot(slot("DAILY_BAR_SLOT"))); - // Slot 0 and slot 26 are always filler in a built view assertEquals(-1, ActivityGui.taskSlot(0)); assertEquals(-1, ActivityGui.taskSlot(26)); } - // The reroll button is a control, not a task - a click there must not be - // mistaken for a task-slot click @Test void theRerollSlotRoutesToNoTask() throws ReflectiveOperationException { assertEquals(-1, ActivityGui.taskSlot(slot("REROLL_SLOT"))); } - // ==================================== - // The revealed task's lore. activityItem() itself builds an ItemStack and - // needs a live registry, so it is unreachable here; activityLore() is the - // whole of what it assembles and is pure. - // ==================================== private static Messages shippedMessages() { ActivityManager manager = TestManagers.manager( new ActivityDef("a", "&aA", Material.PAPER, null, 1, 1, 0)); @@ -275,9 +229,6 @@ void theDescriptionSitsAboveTheProgressAndTodayLines() { messages.get("gui.activity-lore-today-capped", "%today%", 0, "%cap%", 5)), lore); } - // The Today line counts completions (payouts), not points: "every: 1, - // points: 5, daily-cap: 1" reads 0/1 before an action and 1/1 after - - // never "0/5", which players misread as "do it five times" @Test void theTodayLineShowsCompletionsNotPoints() { Messages messages = shippedMessages(); @@ -298,7 +249,6 @@ void theTodayLineShowsCompletionsNotPoints() { ActivityGui.activityLore(messages, def, 3)); } - // No description configured - the lore is exactly what it has always been @Test void anActivityWithoutADescriptionRendersTheSameTwoLines() { Messages messages = shippedMessages(); @@ -311,8 +261,6 @@ void anActivityWithoutADescriptionRendersTheSameTwoLines() { messages.get("gui.activity-lore-today-capped", "%today%", 0, "%cap%", 5)), lore); } - // A description line is operator text, so it goes through colorize the - // way display() does rather than reaching the lore raw @Test void descriptionLinesAreColorized() { List lore = ActivityGui.activityLore(shippedMessages(), described(List.of("#e6ca40&lX")), 0); @@ -321,16 +269,6 @@ void descriptionLinesAreColorized() { assertFalse(lore.get(0).contains("&l"), lore.toString()); } - // ==================================== - // The two gates fromPath() reads are independent by design: a server with - // ItemsAdder and without the TLibs trio must still build its ia. icons, - // and the other way round. All four combinations are pinned because the - // regression this guards against is an '&&' between the two gates, which - // only shows on a server missing one of them. - // - // The real ItemStack constructors need the item registry of a running - // server; the protected no-arg one only nulls a field. - // ==================================== private static final class TestStack extends ItemStack { private final Material type; @@ -368,21 +306,12 @@ void aTLibsIconNeedsOnlyTheItemPathsGate() { assertNull(fromPath("m.material.steel", false, false)); } - // No path configured, and a malformed ia. one, are both "nothing to build" - // - iconStack falls back to the Material for them @Test void noPathAndAMalformedItemsAdderPathBuildNothing() { assertNull(fromPath(null, true, true)); assertNull(fromPath("ia.saucepan", true, true)); } - // ==================================== - // The reveal click's daily-reward hook, through revealTask - the part of - // onClick that runs without an InventoryClickEvent. The reward is an m. - // path with no TLibs behind it, so the attempt shows as a refusal in - // chat: that line is the proof the click asked for the reward, and only - // the last reveal of the draw may produce it. - // ==================================== @Test void revealingTheLastTaskAsksForTheDailyReward() { ActivityManager manager = TestManagers.manager( diff --git a/src/test/java/tfmc/justin/activity/hooks/ItemsAdderItemsTest.java b/src/test/java/tfmc/justin/activity/hooks/ItemsAdderItemsTest.java index 781a521..aba507a 100644 --- a/src/test/java/tfmc/justin/activity/hooks/ItemsAdderItemsTest.java +++ b/src/test/java/tfmc/justin/activity/hooks/ItemsAdderItemsTest.java @@ -18,18 +18,6 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; -// ==================================== -// The logic around the one ItemsAdder call, with the call itself handed in - -// nothing here names an ItemsAdder class, so this runs on a classpath that -// has never heard of dev.lone.itemsadder. -// -// Not reachable headless, and so not covered here: fromItemsAdder(), the -// reflection into the real CustomStack - there is no such class to reflect -// into without ItemsAdder on a running server - and with it the -// Bukkit.getLogger() default instance it backs. What is pinned is everything -// resolve() does around that call, which is where a broken config.yml is -// either survived or not. -// ==================================== class ItemsAdderItemsTest { private final List logged = new ArrayList<>(); @@ -55,8 +43,6 @@ public void close() { return new ItemsAdderItems(creator, logger); } - // The real ItemStack constructors go through the Bukkit registry, which - // needs a running server; the protected no-arg one only nulls a field private static final class TestStack extends ItemStack { private final Material type; private int amount; @@ -85,17 +71,12 @@ public void setAmount(int amount) { this.amount = amount; } - // The real ItemStack#clone goes through the item registry; this is the - // same "a copy, not the original" contract without a server @Override public TestStack clone() { return new TestStack(type, amount); } } - // A copy, never ItemsAdder's own instance: CustomStack#getItemStack() - // hands back the CustomStack's field, and the caller sets a display name - // and lore on what it gets. The single-item amount is set on the copy. @Test void aResolvedIdIsReturnedAsACopy() { ItemStack saucepan = new TestStack(Material.DIAMOND_SWORD, 64); @@ -106,21 +87,16 @@ void aResolvedIdIsReturnedAsACopy() { assertNotSame(saucepan, resolved); assertEquals(Material.DIAMOND_SWORD, resolved.getType()); assertEquals(1, resolved.getAmount()); - // the hook's own stack is untouched by the amount the caller gets assertEquals(64, saucepan.getAmount()); assertTrue(logged.isEmpty(), logged.toString()); } - // null is ItemsAdder saying "no such item", AIR is what an empty - // CustomStack reads as - neither may reach a GUI icon or a payout @Test void anUnknownIdYieldsNoItem() { assertNull(items(id -> null).resolve("tfmc:nope")); assertNull(items(id -> new TestStack(Material.AIR)).resolve("tfmc:nope")); } - // The GUI retries the path on every open and a payout on every claim, so - // the warning must not repeat @Test void anUnknownIdIsLoggedOnceAcrossCalls() { ItemsAdderItems ia = items(id -> null); @@ -134,8 +110,6 @@ void anUnknownIdIsLoggedOnceAcrossCalls() { assertTrue(logged.get(0).contains("could not be resolved"), logged.get(0)); } - // A throwing call must cost its own id only, never the GUI build or the - // claim it happened inside @Test void aThrowingCreatorYieldsNoItemAndIsLoggedOnce() { ItemsAdderItems ia = items(id -> { @@ -149,8 +123,6 @@ void aThrowingCreatorYieldsNoItemAndIsLoggedOnce() { assertTrue(logged.get(0).contains("IllegalStateException"), logged.get(0)); } - // ItemsAdder missing at class-load time surfaces as a LinkageError, not a - // RuntimeException - it must be caught the same way @Test void aLinkageErrorIsCaughtLikeAnyOtherFailure() { ItemsAdderItems ia = items(id -> { @@ -161,12 +133,6 @@ void aLinkageErrorIsCaughtLikeAnyOtherFailure() { assertEquals(1, logged.size(), logged.toString()); } - // ==================================== - // getInstance() answers null for the whole duration of /iareload and of - // ItemsAdder's async pack load, so an id that came back null once is NOT - // known bad - one GUI open inside that window must not cost the icon and - // the reward for the rest of the uptime. Only the log line is silenced. - // ==================================== @Test void anUnresolvedIdIsRetriedOnEveryCall() { int[] calls = {0}; @@ -175,15 +141,12 @@ void anUnresolvedIdIsRetriedOnEveryCall() { assertNull(ia.resolve("tfmc:saucepan")); assertNull(ia.resolve("tfmc:saucepan")); - // ItemsAdder is back assertEquals(Material.DIAMOND_SWORD, ia.resolve("tfmc:saucepan").getType()); assertEquals(3, calls[0]); assertEquals(1, logged.size(), logged.toString()); } - // A throwing call is the genuinely permanent condition, so that id - and - // ItemsAdder's own per-call logging with it - is not asked about again @Test void aThrowingIdStopsCallingTheCreator() { int[] calls = {0}; @@ -199,8 +162,6 @@ void aThrowingIdStopsCallingTheCreator() { assertEquals(1, calls[0]); } - // /activity reload is the operator's way out of that memo without a - // server restart @Test void clearingTheMemosLetsAThrowingIdBeTriedAgain() { int[] calls = {0}; @@ -218,8 +179,6 @@ void clearingTheMemosLetsAThrowingIdBeTriedAgain() { assertEquals(2, logged.size(), logged.toString()); } - // A stack whose getType() throws is ItemsAdder's object too, and must cost - // this icon rather than the GUI build around it @Test void aThrowingStackIsCaughtLikeAThrowingCall() { ItemsAdderItems ia = items(id -> new ItemStack() { @@ -233,8 +192,6 @@ public Material getType() { assertEquals(1, logged.size(), logged.toString()); } - // A good id is never cached: the GUI wants a fresh stack each open, and a - // payout must not hand out the same mutated instance twice @Test void aResolvableIdIsInvokedEveryTime() { int[] calls = {0}; @@ -250,7 +207,6 @@ void aResolvableIdIsInvokedEveryTime() { assertEquals(3, calls[0]); } - // One broken id must not silence a different one @Test void theMemoIsPerId() { ItemsAdderItems ia = items(id -> null); @@ -262,8 +218,6 @@ void theMemoIsPerId() { assertEquals(2, logged.size(), logged.toString()); } - // A resolved id must not stay silenced by an old memo: the id can fail for - // a different reason later, and that later warning must still fire. @Test void aResolvedIdIsUnsilencedForALaterFailure() { int[] fail = {1}; @@ -271,29 +225,17 @@ void aResolvedIdIsUnsilencedForALaterFailure() { ? null : new TestStack(Material.DIAMOND_SWORD)); - ia.resolve("tfmc:saucepan"); // fails, warns once + ia.resolve("tfmc:saucepan"); fail[0] = 0; - ia.resolve("tfmc:saucepan"); // resolves, should clear the memo + ia.resolve("tfmc:saucepan"); fail[0] = 1; - ia.resolve("tfmc:saucepan"); // fails again, must warn again + ia.resolve("tfmc:saucepan"); assertEquals(2, logged.size(), logged.toString()); } - // ==================================== - // fromItemsAdder() is private and reflects into a hardcoded class name - // (dev.lone.itemsadder.api.CustomStack), so the only way to drive a real - // java.lang.reflect.InvocationTargetException through it - the exception - // ItemsAdder's own getInstance()/getItemStack() throws while a pack is - // mid-reload - is to put a fixture class on that exact name on the test - // classpath (src/test/java/dev/lone/itemsadder/api/CustomStack.java) and - // go through the real static entry point, not the creator seam. - // ==================================== @Test void anInvocationTargetExceptionFromItemsAdderIsRetriedOnEveryCall() { - // Default.INSTANCE is built with Bukkit.getLogger(), which NPEs with no - // server installed - and whether an earlier test in this fork already - // installed one depends on class order (it differs between OSes) TestManagers.bukkit(); dev.lone.itemsadder.api.CustomStack.getItemStackCalls = 0; ItemsAdderItems.reset(); diff --git a/src/test/java/tfmc/justin/activity/hooks/TLibsItemsTest.java b/src/test/java/tfmc/justin/activity/hooks/TLibsItemsTest.java index d608608..ce63ef5 100644 --- a/src/test/java/tfmc/justin/activity/hooks/TLibsItemsTest.java +++ b/src/test/java/tfmc/justin/activity/hooks/TLibsItemsTest.java @@ -18,13 +18,6 @@ import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; -// ==================================== -// The logic around the two TLibs calls, with the calls themselves handed in - -// nothing here names a TLibs class, and nothing here builds an ItemStack, -// which blows up headless on the Bukkit registry. The instance is therefore -// parameterised on Material, a plain enum: that also lets the real unresolved -// rule (null / air / TLibs' DIRT sentinel) be the one under test. -// ==================================== class TLibsItemsTest { private final List logged = new ArrayList<>(); @@ -59,8 +52,6 @@ private static List> paths(String... pathThenId) { return paths; } - // Config order decides: the first path the item matches is the activity - // credited, even when a later one would match too @Test void firstMatchingPathWins() { TLibsItems tlibs = items(path -> null, (item, path) -> true); @@ -77,8 +68,6 @@ void anItemMatchingNoPathCreditsNothing() { assertNull(tlibs.firstMatch(Material.STONE, paths())); } - // A deleted MMOItems id throws out of the checker. That must cost its own - // path only - every path after it still gets its chance @Test void aThrowingPathIsSkippedAndTheNextOneStillMatches() { TLibsItems tlibs = items(path -> null, (item, path) -> { @@ -92,7 +81,6 @@ void aThrowingPathIsSkippedAndTheNextOneStillMatches() { paths("m.material.broken", "broken", "m.material.good", "good"))); } - // Every craft of that item would otherwise flood the console @Test void aThrowingPathIsLoggedOnceAcrossCalls() { TLibsItems tlibs = items(path -> null, (item, path) -> { @@ -106,8 +94,6 @@ void aThrowingPathIsLoggedOnceAcrossCalls() { assertTrue(logged.get(0).contains("m.material.broken"), logged.get(0)); } - // null, air and the DIRT sentinel are all TLibs saying "I could not build - // that" - none of them may reach the GUI as an icon @Test void anUnresolvedPathYieldsNoItem() { assertNull(items(path -> null, (item, path) -> false).resolve("m.material.x")); @@ -115,7 +101,6 @@ void anUnresolvedPathYieldsNoItem() { assertNull(items(path -> Material.DIRT, (item, path) -> false).resolve("m.material.x")); } - // The GUI retries the path on every open, so the warning must not repeat @Test void anUnresolvedPathIsLoggedOnceAcrossCalls() { TLibsItems tlibs = items(path -> Material.DIRT, (item, path) -> false); @@ -140,8 +125,6 @@ void aThrowingCreatorYieldsNoItemAndIsLoggedOnce() { assertEquals(1, logged.size(), logged.toString()); } - // A real item comes back untouched - the GUI then puts the activity's own - // name and lore on it @Test void aResolvedPathIsReturnedAsIs() { TLibsItems tlibs = items(path -> Material.DIAMOND_SWORD, (item, path) -> false); @@ -150,8 +133,6 @@ void aResolvedPathIsReturnedAsIs() { assertTrue(logged.isEmpty(), logged.toString()); } - // Once resolve() has seen a path fail to resolve, the creator (and - // TLibs' own per-call logging) must not be invoked again for it @Test void anUnresolvedPathStopsCallingTheCreator() { int[] calls = {0}; @@ -167,7 +148,6 @@ void anUnresolvedPathStopsCallingTheCreator() { assertEquals(1, calls[0]); } - // Same short-circuit for a creator that throws @Test void aThrowingCreatorStopsBeingCalledAgain() { int[] calls = {0}; @@ -183,8 +163,6 @@ void aThrowingCreatorStopsBeingCalledAgain() { assertEquals(1, calls[0]); } - // A path that resolves fine is never cached - the GUI wants a fresh - // stack on every call @Test void aResolvablePathIsInvokedEveryTime() { int[] calls = {0}; diff --git a/src/test/java/tfmc/justin/activity/listeners/CasinoWinListenerTest.java b/src/test/java/tfmc/justin/activity/listeners/CasinoWinListenerTest.java index 972f685..fc9caef 100644 --- a/src/test/java/tfmc/justin/activity/listeners/CasinoWinListenerTest.java +++ b/src/test/java/tfmc/justin/activity/listeners/CasinoWinListenerTest.java @@ -17,18 +17,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; -// ==================================== -// Same technique as MarketSaleListenerTest: CasinoWinListener's constructor -// takes a live-server-backed ActivityManager, so it is built here with a null -// one. Every guard below (null player, non-positive/NaN profit, an unfinished -// carry) returns before the manager is ever touched, so a NullPointerException -// from the null manager is proof the listener tried to credit; its absence -// proves it did not. -// -// Player is stubbed with a Proxy that only answers getUniqueId(), as in -// MarketSaleListenerTest - anything else called on it is a bug in the -// listener under test, not in this stub. -// ==================================== class CasinoWinListenerTest { private static Player stubPlayer(UUID uuid) { @@ -59,21 +47,11 @@ void nonPositiveOrNanProfitsAreIgnoredWithoutTouchingThePlayerOrManager() { CasinoWinListener listener = new CasinoWinListener(null); Player player = stubPlayer(UUID.randomUUID()); - // getUniqueId() would throw from the stub if any of these reached the - // carry, and recordAction() would NPE on the null manager if any of - // these earned a point - neither happens, so nothing is credited assertDoesNotThrow(() -> listener.onPlayerWonMoney(win(player, 0.0))); assertDoesNotThrow(() -> listener.onPlayerWonMoney(win(player, -3.5))); assertDoesNotThrow(() -> listener.onPlayerWonMoney(win(player, Double.NaN))); } - // ==================================== - // The daily-task gate, on a real manager (see TestManagers): a revealed - // task banks its fractions and gets credited; a hidden one banks nothing - // at all, so revealing it later does not hand over what was skipped. - // ==================================== - // 'every: 2' on purpose: a single denar counts but awards no point, which - // keeps the record path away from Bukkit.getPlayer(), unreachable headless private static ActivityManager manager() { return TestManagers.manager(new ActivityDef("casino_win", "casino_win", Material.EMERALD, null, 2, 1, 0)); } @@ -103,15 +81,12 @@ void aHiddenTaskBanksNoFractionAtAll() { listener.onPlayerWonMoney(win(player, 0.5)); assertEquals(0, manager.tasks(uuid).count("casino_win")); - // Revealed only now: the two skipped halves must not still be waiting assertNotNull(manager.reveal(uuid, 0).revealedId()); listener.onPlayerWonMoney(win(player, 0.5)); assertEquals(0, manager.tasks(uuid).count("casino_win")); } - // The carry is per-player: a leftover 0.5 from a quitting player must not - // still be sitting there once they are gone @Test void quittingClearsTheCarrySoALaterHalfWinCreditsNothing() { ActivityManager manager = manager(); diff --git a/src/test/java/tfmc/justin/activity/listeners/CharacterChatListenerTest.java b/src/test/java/tfmc/justin/activity/listeners/CharacterChatListenerTest.java index 04a4021..3a7bfc6 100644 --- a/src/test/java/tfmc/justin/activity/listeners/CharacterChatListenerTest.java +++ b/src/test/java/tfmc/justin/activity/listeners/CharacterChatListenerTest.java @@ -11,11 +11,6 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; -// ==================================== -// The ic_chat anti-farm decision. The event itself needs a server, so this -// drives the static helpers with plain strings, and process() for the -// per-player history rules. -// ==================================== class CharacterChatListenerTest { private final CharacterChatListener listener = new CharacterChatListener(null); @@ -70,7 +65,6 @@ void nineNormalizedCharactersIsBelowTheGate() { assertTrue(say("abcdefghij......")); } - // Digits are dropped by normalization, so number-only differences are repeats (accepted) @Test void messagesDifferingOnlyInNumbersAreRepeats() { assertTrue(say("I hand him 30 silver coins")); @@ -196,7 +190,6 @@ void cyclingTwentyLinesOnlyCountsEachOnce() { cycle(CharacterChatListener.HISTORY); } - // n distinct lines, then the same n again: the repeats never count private void cycle(int n) { for (int i = 0; i < n; i++) { assertTrue(say(line(i)), "first " + i); @@ -206,7 +199,6 @@ private void cycle(int n) { } } - // Distinct lines whose normalized forms are far apart private static String line(int i) { String[] words = {"tavern", "silver", "hooded", "barkeep", "stranger", "window", "candle", "forest", "river", "mountain", "castle", @@ -221,11 +213,8 @@ private static String line(int i) { @Test void similarityThreshold() { String fifty = "a".repeat(50); - // exactly 0.8 is too similar assertTrue(CharacterChatListener.similar(fifty, "a".repeat(40) + "b".repeat(10))); - // 0.78 is not assertFalse(CharacterChatListener.similar(fifty, "a".repeat(39) + "b".repeat(11))); - // length-difference shortcut sits on the same boundary assertTrue(CharacterChatListener.similar(fifty, "a".repeat(40))); assertFalse(CharacterChatListener.similar(fifty, "a".repeat(39))); } @@ -236,12 +225,10 @@ void historyIsCappedOldestEvicted() { assertTrue(say(line(i))); } assertEquals(CharacterChatListener.HISTORY, listener.historySize(player)); - // line 1 is still remembered, line 0 scrolled out assertFalse(say(line(1))); assertTrue(say(line(0))); } - // Relogging must not clear the history, so nothing listens for quits @Test void historyPersistsWithoutQuitHandling() { for (Method m : CharacterChatListener.class.getDeclaredMethods()) { diff --git a/src/test/java/tfmc/justin/activity/listeners/CraftListenerTest.java b/src/test/java/tfmc/justin/activity/listeners/CraftListenerTest.java index 98e91c1..c444d61 100644 --- a/src/test/java/tfmc/justin/activity/listeners/CraftListenerTest.java +++ b/src/test/java/tfmc/justin/activity/listeners/CraftListenerTest.java @@ -8,25 +8,10 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; -// ==================================== -// The whole credit decision, which is the only place a craft can be paid for; -// the event plumbing around it needs a server, and building an ItemStack -// headless blows up on the Bukkit registry, so everything here is primitives. -// -// smallestIngredient is the shortest occupied stack in the crafting grid, read -// at MONITOR - before vanilla's transaction runs. freeSpace is the room for -// the result across the player's 36 storage slots, read at the same moment. -// Both bound vanilla's own shift-click loop, so neither can be inflated by -// anything the player does after the event returns. -// ==================================== class CraftListenerTest { private static final int ROOMY = 64 * 36; - // A normal click is one batch by construction. It is NOT bounded by the - // grid or by free space: vanilla has already decided the click is valid, - // and it may legitimately land on the cursor, on the floor, or in a hotbar - // slot - none of which free space describes @Test void aNormalClickIsAlwaysExactlyOneBatch() { assertEquals(1, CraftListener.creditedAmount(false, 1, 64, ROOMY)); @@ -34,8 +19,6 @@ void aNormalClickIsAlwaysExactlyOneBatch() { assertEquals(3, CraftListener.creditedAmount(false, 3, 0, 0)); } - // Round 1's bug: a full inventory shift-clicking a full grid moved nothing, - // and was paid for the whole batch run anyway @Test void aFullInventoryShiftClickCreditsNothing() { assertEquals(0, CraftListener.creditedAmount(true, 1, 64, 0)); @@ -45,16 +28,12 @@ void aFullInventoryShiftClickCreditsNothing() { @Test void aShiftClickIsBoundedByTheShortestIngredientStack() { assertEquals(7, CraftListener.creditedAmount(true, 1, 7, ROOMY)); - // Four planks per log: the craft count multiplies, it is not the yield assertEquals(28, CraftListener.creditedAmount(true, 4, 7, ROOMY)); } @Test void aShiftClickIsBoundedByTheRoomLeft() { - // Room for 10 items, one per craft assertEquals(10, CraftListener.creditedAmount(true, 1, 64, 10)); - // Room for 10 items, four per craft: two whole batches fit, the third - // would only partly land and is given up assertEquals(8, CraftListener.creditedAmount(true, 4, 64, 10)); } @@ -64,8 +43,6 @@ void anEmptyGridCreditsNothing() { assertEquals(0, CraftListener.creditedAmount(true, 64, 0, ROOMY)); } - // A result stack that reports nothing is not a craft, and dividing by it - // would throw @Test void aZeroSizedResultCreditsNothing() { assertEquals(0, CraftListener.creditedAmount(true, 0, 64, ROOMY)); @@ -73,30 +50,17 @@ void aZeroSizedResultCreditsNothing() { assertEquals(0, CraftListener.creditedAmount(true, -1, 64, ROOMY)); } - // Neither bound may go negative and start paying @Test void negativeBoundsCreditNothing() { assertEquals(0, CraftListener.creditedAmount(true, 1, -5, ROOMY)); assertEquals(0, CraftListener.creditedAmount(true, 1, 64, -5)); } - // ==================================== - // The four ways the old two-tick grid diff was inflated. All of them acted - // on the world *after* the event; the seam below only ever sees the two - // pre-transaction bounds, so each is now worth exactly one real craft. - // ==================================== - - // Ingredients dragged out of the grid in the same tick used to read as - // "consumed". Here the grid was 8 gold nuggets and a carrot at click time, - // one golden carrot fit, and the click was a normal one: one batch @Test void ingredientsPulledOutMidTickCannotInflate() { assertEquals(1, CraftListener.creditedAmount(false, 1, 8, ROOMY)); } - // Thirty craft clicks in one tick used to credit 30*31/2 because each - // click's own 'before' was diffed against one shared 'after'. Each click - // is now its own event and its own single batch @Test void manyClicksInOneTickAreOneBatchEach() { int total = 0; @@ -106,35 +70,17 @@ void manyClicksInOneTickAreOneBatchEach() { assertEquals(30, total); } - // Closing the 2x2 personal grid hands its contents back to the player, - // which used to read as every ingredient consumed. Nothing is read after - // the event any more, so the close is invisible: only the one real craft @Test void closingTheGridCannotInflate() { assertEquals(1, CraftListener.creditedAmount(false, 1, 64, ROOMY)); } - // Walking over dropped result items, or an alt throwing a stack, used to - // raise the resultGained cap and let the inflated grid diff through. Free - // space is measured before the transaction and result items arriving only - // ever shrink it @Test void pickingUpResultItemsCannotInflate() { assertEquals(1, CraftListener.creditedAmount(false, 1, 8, ROOMY)); assertEquals(3, CraftListener.creditedAmount(true, 1, 8, 3)); } - // ==================================== - // The click-type gate. CraftItemEvent fires for *any* click on the result - // slot while the grid matches a recipe, and CraftBukkit's InventoryAction - // is a guess, not a verdict - it reports COLLECT_TO_CURSOR for a - // double-click and HOTBAR_MOVE_AND_READD for a blocked swap, both of which - // craft nothing. So the gate is a whitelist. - // - // ClickType and InventoryAction are plain enums with no registry behind - // them, which is why they can be used here at all. - // ==================================== - private static final boolean OCCUPIED = true; private static final boolean EMPTY = false; @@ -144,18 +90,12 @@ void everyAcceptedClickTakesFromTheResultSlot() { ClickType.LEFT, ClickType.RIGHT, ClickType.SHIFT_LEFT, ClickType.SHIFT_RIGHT, ClickType.DROP, ClickType.CONTROL_DROP}) { assertTrue(CraftListener.takesFromResult(click, InventoryAction.PICKUP_ALL, EMPTY), click.name()); - // These six have no destination slot, so occupancy is irrelevant assertTrue(CraftListener.takesFromResult(click, InventoryAction.PICKUP_ALL, OCCUPIED), click.name()); } assertTrue(CraftListener.takesFromResult(ClickType.NUMBER_KEY, InventoryAction.HOTBAR_SWAP, EMPTY)); assertTrue(CraftListener.takesFromResult(ClickType.SWAP_OFFHAND, InventoryAction.HOTBAR_SWAP, EMPTY)); } - // CRITICAL: vanilla's PICKUP_ALL branch only fires when the clicked slot is - // empty or refuses pickup, and a result slot is neither, so a double-click - // crafts zero items and consumes zero ingredients. CraftBukkit reports - // COLLECT_TO_CURSOR, never NOTHING, so the old blacklist paid a full batch - // for it - unbounded, since the grid is never emptied @Test void aDoubleClickOnTheResultSlotTakesNothing() { assertFalse(CraftListener.takesFromResult(ClickType.DOUBLE_CLICK, InventoryAction.COLLECT_TO_CURSOR, EMPTY)); @@ -163,12 +103,7 @@ void aDoubleClickOnTheResultSlotTakesNothing() { assertFalse(CraftListener.takesFromResult(ClickType.DOUBLE_CLICK, InventoryAction.PICKUP_ALL, EMPTY)); } - // CRITICAL: ResultSlot#mayPlace is always false, so vanilla's swap branch - // needs the destination empty. CraftBukkit reports HOTBAR_MOVE_AND_READD - // either way, so a held number key over a loaded grid used to pay forever @Test - // HOTBAR_MOVE_AND_READD is deprecated but is exactly what CraftBukkit - // still reports for this click, which is the whole point @SuppressWarnings("deprecation") void aNumberKeySwapOntoAnOccupiedSlotTakesNothing() { assertFalse(CraftListener.takesFromResult(ClickType.NUMBER_KEY, InventoryAction.HOTBAR_MOVE_AND_READD, OCCUPIED)); @@ -177,8 +112,6 @@ void aNumberKeySwapOntoAnOccupiedSlotTakesNothing() { } @Test - // HOTBAR_MOVE_AND_READD is deprecated but is exactly what CraftBukkit - // still reports for this click, which is the whole point @SuppressWarnings("deprecation") void anOffhandSwapOntoAnOccupiedOffhandTakesNothing() { assertFalse(CraftListener.takesFromResult(ClickType.SWAP_OFFHAND, InventoryAction.HOTBAR_MOVE_AND_READD, OCCUPIED)); @@ -186,8 +119,6 @@ void anOffhandSwapOntoAnOccupiedOffhandTakesNothing() { assertTrue(CraftListener.takesFromResult(ClickType.SWAP_OFFHAND, InventoryAction.HOTBAR_SWAP, EMPTY)); } - // A drop-click with something already on the cursor is the one no-op - // vanilla and CraftBukkit agree on @Test void nothingActionTakesNothingWhateverTheClick() { assertFalse(CraftListener.takesFromResult(ClickType.DROP, InventoryAction.NOTHING, EMPTY)); @@ -195,8 +126,6 @@ void nothingActionTakesNothingWhateverTheClick() { assertFalse(CraftListener.takesFromResult(ClickType.SHIFT_LEFT, InventoryAction.NOTHING, EMPTY)); } - // Anything not on the whitelist fails closed - including click types that - // do not exist yet @Test void unlistedClickTypesTakeNothing() { for (ClickType click : ClickType.values()) { @@ -210,38 +139,18 @@ void unlistedClickTypesTakeNothing() { } } } - // Named explicitly so the sweep above cannot pass by being empty assertFalse(CraftListener.takesFromResult(ClickType.MIDDLE, InventoryAction.CLONE_STACK, EMPTY)); assertFalse(CraftListener.takesFromResult(ClickType.CREATIVE, InventoryAction.PLACE_ALL, EMPTY)); assertFalse(CraftListener.takesFromResult(ClickType.WINDOW_BORDER_LEFT, InventoryAction.DROP_ALL_CURSOR, EMPTY)); assertFalse(CraftListener.takesFromResult(ClickType.UNKNOWN, InventoryAction.UNKNOWN, EMPTY)); } - // ==================================== - // Is there anything in the result slot at all? - // - // CraftItemEvent is gated on CraftingInventory#getRecipe(), which reads - // ResultContainer's recipeUsed - a field set on every match and cleared - // only by awardUsedRecipes, so it SURVIVES the grid ceasing to match, and - // in the player's own 2x2 menu survives for the whole session. So the - // event still fires on a grid that crafts nothing, with an EMPTY result - // slot. Neither guard above catches it: CraftBukkit calls a left-click - // with a full cursor PLACE_ALL (its empty-clicked-item branch has no - // mayPlace check) and a shift-click MOVE_TO_OTHER_INVENTORY, both on the - // whitelist and neither ever NOTHING. - // - // The old getRecipe().getResult() fallback paid for exactly that click: - // 9 diamonds in, one pulled back out, then click the empty result slot - // forever. The recipe and the grid are not inputs here at all, which is - // the point - an empty result slot is worth nothing whatever they hold. - // ==================================== - private static final boolean PRESENT = true; private static final boolean ABSENT = false; private static final int RESULT_SLOT = 0; @Test - @SuppressWarnings("deprecation") // the sweep covers deprecated InventoryActions on purpose + @SuppressWarnings("deprecation") void anEmptyResultSlotCreditsNothingWhateverTheClick() { for (ClickType click : ClickType.values()) { for (InventoryAction action : InventoryAction.values()) { @@ -252,9 +161,6 @@ void anEmptyResultSlotCreditsNothingWhateverTheClick() { } } - // The two clicks the stale-recipe exploit actually used, named so the - // sweep above cannot pass by being empty - and the same clicks with a real - // result present, so the guard is not just refusing everything @Test void theStaleRecipeExploitClicksCreditNothing() { assertFalse(CraftListener.creditable(RESULT_SLOT, ABSENT, ClickType.LEFT, InventoryAction.PLACE_ALL, EMPTY)); @@ -264,10 +170,6 @@ void theStaleRecipeExploitClicksCreditNothing() { assertTrue(CraftListener.creditable(RESULT_SLOT, PRESENT, ClickType.SHIFT_LEFT, InventoryAction.MOVE_TO_OTHER_INVENTORY, EMPTY)); } - // CraftBukkit only builds a CraftItemEvent for raw slot 0 today, but that - // is an invariant of its code, not a promise of the API. Anywhere else the - // clicked stack is an ingredient, and shift-pulling 64 sticks out of a - // grid would credit 64 sticks crafted @Test void aClickOutsideTheResultSlotCreditsNothing() { for (int rawSlot : new int[]{-1, 1, 2, 4, 9, 36, 45}) { @@ -278,7 +180,6 @@ void aClickOutsideTheResultSlotCreditsNothing() { assertTrue(CraftListener.creditable(RESULT_SLOT, PRESENT, ClickType.SHIFT_LEFT, InventoryAction.MOVE_TO_OTHER_INVENTORY, EMPTY)); } - // The whitelist still has the last word once a result is present @Test void thePresentResultStillHasToBeTakenByTheClick() { assertFalse(CraftListener.creditable(RESULT_SLOT, PRESENT, ClickType.DOUBLE_CLICK, InventoryAction.COLLECT_TO_CURSOR, EMPTY)); diff --git a/src/test/java/tfmc/justin/activity/listeners/DishCookedListenerTest.java b/src/test/java/tfmc/justin/activity/listeners/DishCookedListenerTest.java index 580e0f4..97d2053 100644 --- a/src/test/java/tfmc/justin/activity/listeners/DishCookedListenerTest.java +++ b/src/test/java/tfmc/justin/activity/listeners/DishCookedListenerTest.java @@ -14,23 +14,6 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertThrows; -// ==================================== -// DishCookedListener needs a live server for a real ActivityManager, so it is -// built with a null manager here, same as MarketSaleListenerTest - a -// NullPointerException from the null manager is used as a witness that the -// listener tried to record a dish. -// -// DishCookedEvent's own constructor unconditionally clones its ItemStack -// argument (result.clone()), and building any real ItemStack headless blows -// up on the Bukkit Material registry (see TLibsItemsTest) - and passing null -// would NPE inside the constructor itself, before this listener ever sees the -// event. The listener under test never reads getResult(), so the event is -// built here by bypassing its constructor via the same -// newConstructorForSerialization trick common serialization libraries use: -// it allocates the object and runs only Object's constructor, then the -// player/method fields are set directly by reflection. That sidesteps the -// clone() call entirely instead of needing a live server. -// ==================================== class DishCookedListenerTest { private static Player stubPlayer(UUID uuid) { @@ -73,10 +56,6 @@ void aNullPlayerIsIgnored() { assertDoesNotThrow(() -> listener.onDishCooked(dish(null))); } - // Sanity check that the NPE-as-witness approach above actually detects a - // credit: a real player cooking a dish DOES reach the manager, which - // requires the null manager and blows up - proving the null-player test - // above is not vacuously passing @Test void aCookedDishForARealPlayerReachesTheManager() { DishCookedListener listener = new DishCookedListener(null); diff --git a/src/test/java/tfmc/justin/activity/listeners/FractionCarryTest.java b/src/test/java/tfmc/justin/activity/listeners/FractionCarryTest.java index 73c4989..c442d0f 100644 --- a/src/test/java/tfmc/justin/activity/listeners/FractionCarryTest.java +++ b/src/test/java/tfmc/justin/activity/listeners/FractionCarryTest.java @@ -6,13 +6,10 @@ import static org.junit.jupiter.api.Assertions.assertEquals; -// Only the fraction arithmetic: the listeners themselves need a running server class FractionCarryTest { - // The cycle every fraction in these tests is banked on private static final Keys DAY = new Keys("2026-W38", "2026-09-17"); - // The same week, the next day private static final Keys NEXT_DAY = new Keys("2026-W38", "2026-09-18"); @Test @@ -49,7 +46,6 @@ void theFractionIsCarriedNotDiscarded() { assertEquals(0.0, second.carry(), 1e-9); } - // A profession tuned below 1 xp per action must still credit eventually @Test void repeatedSubOneGainsEventuallyCredit() { double carry = 0; @@ -62,8 +58,6 @@ void repeatedSubOneGainsEventuallyCredit() { assertEquals(5, total); } - // What the market listener relies on: sub-denar sales accumulate per - // player per activity instead of being rounded away @Test void carryAccumulatesPerPlayerAndActivity() { FractionCarry carry = new FractionCarry(); @@ -73,19 +67,15 @@ void carryAccumulatesPerPlayerAndActivity() { assertEquals(0, carry.add(one, "market_sale", 0.5, DAY)); assertEquals(1, carry.add(one, "market_sale", 0.5, DAY)); - // A different activity keeps its own leftover assertEquals(0, carry.add(one, "other", 0.5, DAY)); - // ...and so does a different player assertEquals(0, carry.add(two, "market_sale", 0.5, DAY)); - // Forgetting a player drops only their leftover assertEquals(0, carry.add(one, "market_sale", 0.5, DAY)); carry.forget(one); assertEquals(0, carry.add(one, "market_sale", 0.5, DAY)); assertEquals(1, carry.add(two, "market_sale", 0.5, DAY)); } - // A poisoned value contributes nothing and leaves the carry intact @Test void poisonedValuesCannotCorruptTheCarry() { assertEquals(0.5, FractionCarry.credit(0.5, Double.NaN).carry(), 1e-9); @@ -93,8 +83,6 @@ void poisonedValuesCannotCorruptTheCarry() { assertEquals(0, FractionCarry.credit(0.5, Double.NaN).amount()); } - // A market sale worth several denar and change: the whole part is - // credited immediately and the change keeps accumulating @Test void aMultiDenarSaleCreditsItsWholePartAndKeepsTheChange() { FractionCarry carry = new FractionCarry(); @@ -104,8 +92,6 @@ void aMultiDenarSaleCreditsItsWholePartAndKeepsTheChange() { assertEquals(1, carry.add(player, "market_sale", 0.25, DAY)); } - // A poisoned market sale (free, refunded, or a bad double from the - // source plugin) must not eat into or fabricate the player's carry @Test void aWorthlessMarketSaleLeavesLaterSalesUnaffected() { FractionCarry carry = new FractionCarry(); @@ -115,16 +101,10 @@ void aWorthlessMarketSaleLeavesLaterSalesUnaffected() { assertEquals(0, carry.add(player, "market_sale", -10.0, DAY)); assertEquals(0, carry.add(player, "market_sale", Double.NaN, DAY)); - // The carry is still exactly what a fresh player would have: two - // ordinary half-denar sales still take two sales to earn one point assertEquals(0, carry.add(player, "market_sale", 0.5, DAY)); assertEquals(1, carry.add(player, "market_sale", 0.5, DAY)); } - // +Infinity is not worthless - it is clamped to Integer.MAX_VALUE rather - // than credited as nothing (see absurdValuesClampInsteadOfOverflowing) - - // but it must still leave a clean 0 carry behind for whatever the player - // sells next, not some unrepresentable fractional remainder @Test void anInfiniteMarketSaleClampsAndLeavesACleanCarryBehind() { FractionCarry carry = new FractionCarry(); @@ -132,7 +112,6 @@ void anInfiniteMarketSaleClampsAndLeavesACleanCarryBehind() { assertEquals(Integer.MAX_VALUE, carry.add(player, "market_sale", Double.POSITIVE_INFINITY, DAY)); - // A fresh-looking pair of half sales still takes two to earn one point assertEquals(0, carry.add(player, "market_sale", 0.5, DAY)); assertEquals(1, carry.add(player, "market_sale", 0.5, DAY)); } @@ -147,8 +126,6 @@ void carryNeverReachesAWholePoint() { } } - // Cent-denominated sales must not drift: summed as doubles, 250 additions - // of 0.10 fall short of 25 and the player never reaches the goal @Test void centSizedSalesAccumulateExactly() { FractionCarry carry = new FractionCarry(); @@ -160,8 +137,6 @@ void centSizedSalesAccumulateExactly() { } assertEquals(25, total); - // ...and nothing is left over: a 0.9 sale on a zero carry credits - // nothing, where a drifted 0.999... carry would credit a point assertEquals(0, carry.add(player, "market_sale", 0.9, DAY)); assertEquals(1, carry.add(player, "market_sale", 0.1, DAY)); } @@ -177,11 +152,6 @@ void tenthsSumToAWholePointWithoutDrift() { assertEquals(1, carry.add(player, "market_sale", 0.7, DAY)); } - // ==================================== - // The gate refusing an activity drops what it had banked, so a fraction - // from a day the task was revealed cannot pay out days later when it is - // drawn and revealed again. Other activities keep theirs. - // ==================================== @Test void aRefusedActivityLosesItsCarryButTheOthersKeepTheirs() { FractionCarry carry = new FractionCarry(); @@ -192,9 +162,7 @@ void aRefusedActivityLosesItsCarryButTheOthersKeepTheirs() { carry.forget(player, "market_sale"); - // Banked fraction gone: 0.9 on a clean carry is still short of a point assertEquals(0, carry.add(player, "market_sale", 0.9, DAY)); - // Untouched: 0.9 + 0.9 is worth one assertEquals(1, carry.add(player, "casino_win", 0.9, DAY)); } @@ -209,27 +177,16 @@ void forgettingAnActivityNobodyBankedIsHarmless() { assertEquals(1, carry.add(player, "market_sale", 0.5, DAY)); } - // ==================================== - // The daily counters a fraction feeds are wiped at the rollover, so the - // fraction must not outlive the day it was banked on: a player online - // across midnight would otherwise be paid today for yesterday's leftover. - // ==================================== @Test void aFractionNeverCrossesADayBoundary() { FractionCarry carry = new FractionCarry(); java.util.UUID player = java.util.UUID.randomUUID(); assertEquals(0, carry.add(player, "market_sale", 0.9, DAY)); - // 0.9 + 0.9 would be worth one on the same day assertEquals(0, carry.add(player, "market_sale", 0.9, NEXT_DAY)); assertEquals(1, carry.add(player, "market_sale", 0.1, NEXT_DAY)); } - // ==================================== - // The week key flips at the configured reset hour, which wipes the daily - // counters while the day key is unchanged - a fraction banked minutes - // before the reset must not pay out into the new week. - // ==================================== @Test void aFractionNeverCrossesAWeekBoundaryEither() { FractionCarry carry = new FractionCarry(); @@ -237,12 +194,10 @@ void aFractionNeverCrossesAWeekBoundaryEither() { Keys nextWeek = new Keys("2026-W39", DAY.day()); assertEquals(0, carry.add(player, "market_sale", 0.9, DAY)); - // Same day, new week: the bank is gone, so 0.9 is still short of one assertEquals(0, carry.add(player, "market_sale", 0.9, nextWeek)); assertEquals(1, carry.add(player, "market_sale", 0.1, nextWeek)); } - // Every player's bank is dropped, not just the one whose event ran first @Test void theRolloverDropsEveryPlayersFraction() { FractionCarry carry = new FractionCarry(); diff --git a/src/test/java/tfmc/justin/activity/listeners/MarketSaleListenerTest.java b/src/test/java/tfmc/justin/activity/listeners/MarketSaleListenerTest.java index 78458d4..ec46dc0 100644 --- a/src/test/java/tfmc/justin/activity/listeners/MarketSaleListenerTest.java +++ b/src/test/java/tfmc/justin/activity/listeners/MarketSaleListenerTest.java @@ -17,19 +17,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; -// ==================================== -// MarketSaleListener needs a live server for a real ActivityManager (its -// constructor is a private Bukkit-backed singleton), so the listener is -// built with a null manager here. That works because the manager is only -// ever touched once a sale actually earns a whole point - every guard below -// (null player, non-positive price, an unfinished carry) returns before that -// point. A NullPointerException from the null manager is therefore used as a -// witness that the listener tried to credit; its absence proves it did not. -// -// Player is an interface, so a bare stub that only answers getUniqueId() (and -// fails loudly if anything else is called) stands in for a real one without a -// mocking library. -// ==================================== class MarketSaleListenerTest { private static Player stubPlayer(UUID uuid) { @@ -60,21 +47,11 @@ void nonPositivePricesAreIgnoredWithoutTouchingThePlayerOrManager() { MarketSaleListener listener = new MarketSaleListener(null); Player player = stubPlayer(UUID.randomUUID()); - // getUniqueId() would throw from the stub if any of these reached the - // carry, and recordAction() would NPE on the null manager if any of - // these earned a point - neither happens, so nothing is credited assertDoesNotThrow(() -> listener.onMarketSale(sale(player, 0.0))); assertDoesNotThrow(() -> listener.onMarketSale(sale(player, -3.5))); assertDoesNotThrow(() -> listener.onMarketSale(sale(player, Double.NaN))); } - // ==================================== - // The daily-task gate, on a real manager (see TestManagers): a revealed - // task banks its fractions and gets credited; a hidden one banks nothing - // at all, so revealing it later does not hand over what was skipped. - // ==================================== - // 'every: 2' on purpose: a single denar counts but awards no point, which - // keeps the record path away from Bukkit.getPlayer(), unreachable headless private static ActivityManager manager() { return TestManagers.manager(new ActivityDef("market_sale", "market_sale", Material.EMERALD, null, 2, 1, 0)); } @@ -104,15 +81,12 @@ void aHiddenTaskBanksNoFractionAtAll() { listener.onMarketSale(sale(player, 0.5)); assertEquals(0, manager.tasks(uuid).count("market_sale")); - // Revealed only now: the two skipped halves must not still be waiting assertNotNull(manager.reveal(uuid, 0).revealedId()); listener.onMarketSale(sale(player, 0.5)); assertEquals(0, manager.tasks(uuid).count("market_sale")); } - // The carry is per-player: a leftover 0.5 from a quitting player must not - // still be sitting there once they are gone @Test void quittingClearsTheCarrySoALaterHalfSaleCreditsNothing() { ActivityManager manager = manager(); diff --git a/src/test/java/tfmc/justin/activity/listeners/MmoItemsStationListenerTest.java b/src/test/java/tfmc/justin/activity/listeners/MmoItemsStationListenerTest.java index f25b834..b4d3212 100644 --- a/src/test/java/tfmc/justin/activity/listeners/MmoItemsStationListenerTest.java +++ b/src/test/java/tfmc/justin/activity/listeners/MmoItemsStationListenerTest.java @@ -32,24 +32,6 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; -// ==================================== -// PlayerUseCraftingStationEvent, CraftingStation and Recipe all need a live -// MMOItems/PlayerData setup to build through their real constructors (Recipe -// is even abstract). The listener under test only ever calls -// getInteraction()/getStation()/getRecipe()/getPlayer() and, through those, -// CraftingStation#getId()/Recipe#getId() - so every one of these is built by -// bypassing its constructor via ReflectionFactory, exactly like -// DishCookedListenerTest builds its event, and only the handful of fields the -// listener actually reads are set by reflection. -// -// ActivityManager is also a Bukkit-backed private-constructor singleton, so -// it is built the same way, with its 'config' field pointed at a real -// ActivityConfiguration (built the way ActivityConfigurationStationTest -// builds one) and its 'store' field left null. recordAction() reads -// config.activity(id) first and only reaches the null store on a real match, -// so a NullPointerException out of store.get(uuid) is proof the listener -// found a match and tried to record it; its absence proves it did not. -// ==================================== class MmoItemsStationListenerTest { private static Player stubPlayer(UUID uuid) { @@ -93,11 +75,6 @@ private static CraftingStation station(String id) { return station; } - // Recipe is abstract - ReflectionFactory's bypass allocator still runs - // the JVM's own object-allocation check, which rejects an abstract class - // (InstantiationError) even without calling any constructor. A trivial - // concrete subclass, itself never constructed normally either, sidesteps - // that; its three abstract methods are never called by the listener. private static final class TestRecipe extends Recipe { private TestRecipe() { super(null); @@ -128,10 +105,6 @@ private static Recipe recipe(String id) { return recipe; } - // The listener never reads the result at all any more, but events that - // carry one are still worth exercising; an empty bypassed shell - built - // the same way, sidestepping ItemStack's Material-registry-touching - // constructor - is enough to make it non-null. private static ItemStack anyResult() { return bypassNew(ItemStack.class); } @@ -152,9 +125,6 @@ private static PlayerUseCraftingStationEvent event(Player player, CraftingStatio return event; } - // A real ActivityConfiguration with its 'stationActivities' and - // 'activities' maps set directly, skipping load() (needs a live plugin) - // entirely - the listener only ever reaches these two lookups. private static ActivityConfiguration configWithStation(String stationKey, String activityId) { ActivityConfiguration config = bypassNew(ActivityConfiguration.class); @@ -172,8 +142,6 @@ private static ActivityConfiguration configWithStation(String stationKey, String private static ActivityManager managerWithConfig(ActivityConfiguration config) { ActivityManager manager = bypassNew(ActivityManager.class); setField(manager, ActivityManager.class, "config", config); - // 'store' is left null on purpose: recordAction() only reaches it - // after a real activity match, so touching it is the witness. return manager; } @@ -189,8 +157,6 @@ void aResultWithAMatchingStationRecordsOneAction() { assertThrows(NullPointerException.class, () -> listener.onUseCraftingStation(craft)); } - // Regression: 'output-item: false' recipes hand over no ItemStack, so both - // completed-craft paths fire with result == null. They must still count. @ParameterizedTest @EnumSource(value = StationAction.class, names = {"INSTANT_RECIPE", "CRAFTING_QUEUE"}) void aCompletedCraftWithoutAnOutputItemStillRecordsOneAction(StationAction action) { @@ -204,8 +170,6 @@ void aCompletedCraftWithoutAnOutputItemStillRecordsOneAction(StationAction actio assertThrows(NullPointerException.class, () -> listener.onUseCraftingStation(craft)); } - // Queueing, cancelling and upgrading are not completed crafts, with or - // without an output item. @ParameterizedTest @EnumSource(value = StationAction.class, names = {"INTERACT_WITH_RECIPE", "CANCEL_QUEUE", "UPGRADE_RECIPE"}) void nonCraftInteractionsRecordNothing(StationAction action) { @@ -266,11 +230,6 @@ void aNullPlayerIsIgnored() { assertDoesNotThrow(() -> listener.onUseCraftingStation(craft)); } - // Sanity check that the NPE-as-witness approach above is not vacuous: - // prove a genuine match really does reach the manager by also checking it - // via the un-guarded static wiring below - MONITOR + ignoreCancelled, and - // no manager call happens before interaction/station/recipe/player all - // check out, which the six tests above already exercise from both sides. @Test void handlerIsMonitorPriorityAndIgnoresCancelled() throws NoSuchMethodException { Method method = MmoItemsStationListener.class.getMethod("onUseCraftingStation", PlayerUseCraftingStationEvent.class); diff --git a/src/test/java/tfmc/justin/activity/managers/ActivityManagerClickCommandsTest.java b/src/test/java/tfmc/justin/activity/managers/ActivityManagerClickCommandsTest.java index e0db03a..e2642c2 100644 --- a/src/test/java/tfmc/justin/activity/managers/ActivityManagerClickCommandsTest.java +++ b/src/test/java/tfmc/justin/activity/managers/ActivityManagerClickCommandsTest.java @@ -27,39 +27,10 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; -// ==================================== -// The console-dispatch half of an activity's 'click-commands': -// ActivityManager.dispatchCommands, which takes the console call and the -// logger rather than reaching for Bukkit and the plugin, so the substitution, -// the name guard and the throw guard can be driven headless - and -// clickCooldownPassed, the rate limit, which takes its clock reading rather -// than sleeping on a real one. -// -// runClickCommands itself is driven too, as far as headless goes: every -// refusal it can answer with (an unrevealed task, a slot outside the draw, an -// activity with no click-commands, a name no command can be run for) is -// decided before it reaches a scheduler, and a click that gets past all of -// them proves the guard around the scheduler rather than the dispatch. -// -// The next-tick body is driven directly, through runClickCommandsNow, which -// takes the console call the same way dispatchCommands does: the close, the -// dispatch, their ordering and the audit line are all real code here. -// -// Not reachable headless: Bukkit.dispatchCommand itself (the CONSOLE -// constant), and the scheduler hop that gets from the click tick to the next -// one - runTask needs a live server. The stub player stands in for exactly -// the calls made on him; everything between them is the real code. -// -// The GUI's quit handler is a Bukkit event and cannot fire here either; what -// it calls, forgetClickCooldown, is driven directly. -// ==================================== class ActivityManagerClickCommandsTest { private final List logged = new ArrayList<>(); - // The broken-command memo is static and lives until a reload, exactly as - // it does on a server - so each test starts from an empty one rather than - // from whatever the test before it reported @BeforeEach void forgetReportedCommands() { ActivityManager.reportedBrokenCommands.clear(); @@ -90,8 +61,6 @@ private static Player player(String name, UUID uuid) { InvocationHandler handler = (proxy, method, args) -> switch (method.getName()) { case "getName" -> name; case "getUniqueId" -> uuid; - // Nothing is open on a headless player; the close is only ever - // reached on the tick after the click, which needs a scheduler case "getOpenInventory" -> null; case "toString" -> "stub-player"; case "hashCode" -> 1; @@ -117,8 +86,6 @@ void bothPlaceholdersAreSubstituted() { assertEquals(List.of("sudo Notch votelist", "lp user " + uuid + " parent add vip"), ran); } - // The name guard is per command: an unsafe name skips the ones that paste - // it and leaves the %uuid%-only ones working @Test void anUnsafeNameSkipsThePlayerCommandAndStillRunsTheUuidOne() { UUID uuid = UUID.randomUUID(); @@ -141,7 +108,6 @@ void everyCommandSkippedMeansNothingRan() { assertEquals(List.of(), ran); } - // A third-party command that throws must not unwind into the click handler @Test void aThrowingCommandIsLoggedAndTheRestStillRun() { List ran = new ArrayList<>(); @@ -167,8 +133,6 @@ void aThrowingOnlyCommandCountsAsNothingRan() { })); } - // The reward call site passes its own word, so one dispatcher never - // mislabels the other's log line @Test void theLabelNamesTheKindOfCommandInTheLogLine() { ActivityManager.dispatchCommands(player("Bedrock Player", UUID.randomUUID()), @@ -195,8 +159,6 @@ void aClickAfterTheIntervalPassesAgain() { assertTrue(manager.clickCooldownPassed(uuid, 2_000_000_000L, 1_000_000_000L)); } - // Per player, not per activity: what is bounded is how often one player - // can make the console run anything @Test void oneCooledDownPlayerDoesNotBlockAnother() { ActivityManager manager = TestManagers.manager(); @@ -205,8 +167,6 @@ void oneCooledDownPlayerDoesNotBlockAnother() { assertTrue(manager.clickCooldownPassed(UUID.randomUUID(), 1_000_000_000L, 1_000_000_000L)); } - // A blocked click must not push the window out, or a player clicking - // faster than the interval would never come off cooldown @Test void aBlockedClickDoesNotPushTheCooldownOut() { ActivityManager manager = TestManagers.manager(); @@ -218,8 +178,6 @@ void aBlockedClickDoesNotPushTheCooldownOut() { assertTrue(manager.clickCooldownPassed(uuid, 2_000_000_000L, 1_000_000_000L)); } - // What the GUI's quit handler does - without it the map would keep an - // entry per player who ever clicked a task, for the whole uptime @Test void quittingDropsThePlayersCooldownEntry() { ActivityManager manager = TestManagers.manager(); @@ -244,19 +202,11 @@ void forgettingAPlayerWhoNeverClickedIsHarmless() { assertEquals(0, manager.clickCooldownEntries()); } - // ==================================== - // The reveal/run boundary, which is the whole contract of a click - // command: a task only runs anything once it is revealed, and it is the - // manager - not the caller's branch ordering - that decides so. - // ==================================== private static ActivityManager withVote() { return TestManagers.manager(new ActivityDef("vote", "Vote", Material.PAPER, null, 1, 1, 0, List.of("sudo %player% votelist"), List.of())); } - // The first click on a hidden task reveals it and the second finds - // nothing to reveal - which is the state the GUI reads to decide that a - // click is a run rather than a reveal @Test void theFirstClickRevealsAndASecondClickRevealsNothing() { ActivityManager manager = withVote(); @@ -270,16 +220,6 @@ void theFirstClickRevealsAndASecondClickRevealsNothing() { assertFalse(second.drawChanged()); } - // ==================================== - // The invariant the GUI used to hold on its own: an unrevealed task - // dispatches nothing, however it is reached. - // - // The cooldown is what pins it. false on its own proves nothing here: a - // click that got past the reveal check would still answer false, because - // the scheduler behind it throws headless - so the assertion would hold - // with the guard deleted. Nothing may have been committed either, and the - // cooldown is the first thing this path commits. - // ==================================== @Test void anUnrevealedTaskRunsNothing() { ActivityManager manager = withVote(); @@ -300,9 +240,6 @@ void aSlotOutsideTheDrawRunsNothing() { assertFalse(manager.runClickCommands(player("Notch", uuid), 99)); } - // Same reason as above for the cooldown assertion: false alone is what - // the scheduler guard answers anyway, so only "nothing was committed" - // tells the empty list apart from a click that ran off the end @Test void aRevealedTaskWithoutClickCommandsRunsNothing() { ActivityManager manager = @@ -314,8 +251,6 @@ void aRevealedTaskWithoutClickCommandsRunsNothing() { assertEquals(0, manager.clickCooldownEntries()); } - // Every command skipped by the name guard means nothing will run, and a - // click that will run nothing must not report success or take a cooldown @Test void aRevealedTaskWhoseEveryCommandIsSkippedRunsNothing() { ActivityManager manager = withVote(); @@ -326,13 +261,6 @@ void aRevealedTaskWhoseEveryCommandIsSkippedRunsNothing() { assertEquals(0, manager.clickCooldownEntries()); } - // ==================================== - // A click with something to run gets as far as the scheduler, which is - // where a headless run stops: there is no server behind getScheduler(). - // What that pins is the guard around it - a plugin that is disabling - // throws there too, and it must come back as a refused click rather than - // unwind through the inventory click handler. - // ==================================== @Test void aClickThatCannotBeScheduledIsRefusedRatherThanThrown() { ActivityManager manager = withVote(); @@ -360,15 +288,10 @@ public void close() { TestManagers.logger().removeHandler(capture); } - // It really did get past both rate limits and as far as the scheduler assertEquals(1, manager.clickCooldownEntries()); assertTrue(logged("Could not schedule an activity click"), logged.toString()); } - // ==================================== - // What one click will actually dispatch, decided before anything is - // committed. - // ==================================== private static ActivityDef listing(String... commands) { return new ActivityDef("vote", "Vote", Material.PAPER, null, 1, 1, 0, List.of(commands), List.of()); } @@ -385,8 +308,6 @@ void aNameNoCommandCanBeRunForLeavesNothingToRun() { listing("sudo %player% votelist"), "Bedrock Player", 5, logger())); } - // The cooldown counts a click and not a command, so a long list would - // otherwise multiply the cost of every click by its own length @Test void onlyTheFirstFewCommandsOfALongListAreRun() { assertEquals(List.of("say 1", "say 2"), ActivityManager.runnableClickCommands( @@ -394,7 +315,6 @@ void onlyTheFirstFewCommandsOfALongListAreRun() { assertTrue(logged("lists more than 2 click-commands"), logged.toString()); } - // ...and the operator hears about it once, not once per click @Test void theCapIsReportedOnlyOnce() { Logger logger = logger(); @@ -407,11 +327,6 @@ void theCapIsReportedOnlyOnce() { .contains("lists more than 1")).count(), logged.toString()); } - // ==================================== - // The server-wide ceiling the per-player cooldown cannot give. A peek - // must not spend a token, or a click the player's own cooldown goes on to - // refuse would cost every other player one. - // ==================================== @Test void aPeekDoesNotSpendAToken() { ActivityManager manager = TestManagers.manager(); @@ -439,12 +354,9 @@ void theBucketRefillsOverTime() { manager.clickBudget(1_000_000_000L, 2, true); assertFalse(manager.clickBudget(1_000_000_000L, 2, false)); - // Half a second at two a second is one token back assertTrue(manager.clickBudget(1_500_000_000L, 2, true)); } - // One second's worth at most: an idle hour must not bank an hour of - // dispatches for one burst @Test void theBucketNeverHoldsMoreThanASecondsWorth() { ActivityManager manager = TestManagers.manager(); @@ -457,12 +369,6 @@ void theBucketNeverHoldsMoreThanASecondsWorth() { assertFalse(manager.clickBudget(later, 2, true)); } - // ==================================== - // A command the console refused - unknown on this server, which the - // shipped 'sudo %player% votelist' is without CMI or EssentialsX. It - // still counts as run, the way it always has and the way the reward - // payout's rollback rule is built on, but it is reported, and only once. - // ==================================== @Test void aRefusedCommandStillCountsAsRunAndIsReportedOnce() { Logger logger = logger(); @@ -476,8 +382,6 @@ void aRefusedCommandStillCountsAsRunAndIsReportedOnce() { .contains("was refused by the console")).count(), logged.toString()); } - // The reward path reports its own, so one kind of command going quiet - // does not silence the other @Test void aRefusedRewardCommandIsReportedSeparatelyFromAClickOne() { Logger logger = logger(); @@ -493,8 +397,6 @@ void aRefusedRewardCommandIsReportedSeparatelyFromAClickOne() { .contains("The reward command")).count(), logged.toString()); } - // A command that throws is logged once too - it was once per click per - // player before, for the whole uptime @Test void aThrowingCommandIsLoggedOnlyOnce() { Logger logger = logger(); @@ -510,16 +412,6 @@ void aThrowingCommandIsLoggedOnlyOnce() { .contains("threw for")).count(), logged.toString()); } - // ==================================== - // The tick after the click: the close, the dispatch, the order they - // happen in and the audit line. Driven through runClickCommandsNow, which - // takes the console call, so everything the scheduled runnable does is - // real code here - only the scheduler hop that gets to it is not. - // - // One recorder list takes both the close and each dispatched command, so - // the ORDER is what is asserted and not just that both happened: swapping - // them back fails these outright. - // ==================================== private static final ClassLoader LOADER = ActivityManagerClickCommandsTest.class.getClassLoader(); private static Object proxy(Class type, InvocationHandler handler) { @@ -536,14 +428,6 @@ private static InventoryView viewOf(Inventory top) { }); } - // ==================================== - // A player looking at a window whose top inventory has this holder. A - // Marker is the activity menu; anything else is somebody else's window. - // - // getOpenInventory hands back a FRESH view object every call on purpose: - // nothing in Bukkit promises the same one twice, and the close must not - // depend on it. - // ==================================== private static Player lookingAt(String name, UUID uuid, InventoryHolder holder, List record) { Inventory top = (Inventory) proxy(Inventory.class, (p, method, args) -> switch (method.getName()) { case "getHolder" -> holder; @@ -567,7 +451,6 @@ private static Player lookingAt(String name, UUID uuid, InventoryHolder holder, }); } - // The audit line lands in the stub plugin's own logger, not in a local one private void capturingPluginLog(Runnable work) { Handler capture = new Handler() { @Override @@ -603,8 +486,6 @@ void theMenuIsClosedBeforeTheCommandIsDispatched() { assertEquals(List.of("close", "sudo Notch votelist"), record); } - // A player who closed the menu himself and opened something else does not - // get that closed out from under him @Test void anotherOpenWindowIsNotClosed() { ActivityManager manager = withVote(); @@ -647,9 +528,6 @@ void aRefusedCommandIsAuditedAsRefused() { assertTrue(logged("command=\"sudo Notch votelist\" result=refused"), logged.toString()); } - // The case an operator would actually be investigating - it used to leave - // no per-player line at all, because the audit line was written after the - // console call returned @Test void aThrowingCommandIsStillAudited() { auditedRun(command -> { @@ -659,8 +537,6 @@ void aThrowingCommandIsStillAudited() { assertTrue(logged("command=\"sudo Notch votelist\" result=threw"), logged.toString()); } - // A refusal and a throw are different failures and are memoed apart, so - // one does not silence the other for the rest of the uptime @Test void aCommandThatThrewIsStillReportedWhenItIsLaterRefused() { Logger logger = logger(); @@ -676,31 +552,20 @@ void aCommandThatThrewIsStillReportedWhenItIsLaterRefused() { assertTrue(logged("was refused by the console"), logged.toString()); } - // ==================================== - // The server-wide ceiling counts commands, which is what config.yml - // documents it as - one click dispatching five of them costs five tokens, - // not one. - // ==================================== @Test void aClickPaysForEveryCommandItDispatches() { ActivityManager manager = TestManagers.manager(); - // A full bucket of 10, spent by two clicks of 5 assertTrue(manager.clickBudget(1_000_000_000L, 10, 5, true)); assertTrue(manager.clickBudget(1_000_000_000L, 10, 5, true)); assertFalse(manager.clickBudget(1_000_000_000L, 10, 5, false)); } - // A click needing more than the whole bucket runs once and then waits, - // rather than being refused forever - which is what a config with - // click-commands-per-second below click-commands-per-click would otherwise - // mean @Test void aClickBiggerThanTheBucketOverdrawsOnceAndThenWaits() { ActivityManager manager = TestManagers.manager(); assertTrue(manager.clickBudget(1_000_000_000L, 2, 5, true)); - // -3 tokens, repaid at 2 a second assertFalse(manager.clickBudget(1_000_000_000L, 2, 5, false)); assertFalse(manager.clickBudget(2_000_000_000L, 2, 5, false)); assertTrue(manager.clickBudget(3_000_000_000L, 2, 5, false)); diff --git a/src/test/java/tfmc/justin/activity/managers/ActivityManagerDailyRewardTest.java b/src/test/java/tfmc/justin/activity/managers/ActivityManagerDailyRewardTest.java index accda58..93f0ec1 100644 --- a/src/test/java/tfmc/justin/activity/managers/ActivityManagerDailyRewardTest.java +++ b/src/test/java/tfmc/justin/activity/managers/ActivityManagerDailyRewardTest.java @@ -33,13 +33,6 @@ import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; -// ==================================== -// daily-reward: ActivityManager.claimDailyReward on a real manager built -// without a server (see TestManagers). The store is pointed at a temp file, -// since the payout saves before it pays; the item check and the payout are -// the seams the package-private overload takes, because a real item needs a -// running server. -// ==================================== class ActivityManagerDailyRewardTest { @TempDir @@ -53,12 +46,10 @@ class ActivityManagerDailyRewardTest { private ActivityManager manager; private final UUID uuid = UUID.randomUUID(); private final List chat = new ArrayList<>(); - // What each payout was handed, and what it answers private final List paid = new ArrayList<>(); private final List paidMultipliers = new ArrayList<>(); private boolean payWorks = true; private boolean resolves = true; - // Whether the claim was already on disk when the payout ran private final List onDiskAtPayout = new ArrayList<>(); @BeforeEach @@ -76,7 +67,6 @@ void setUp() { ActivityManager.reportedItemPaths.clear(); } - // A player whose group. nodes are set exactly as given private Player player(Set permissions) { return player(permissions::contains, permissions::contains); } @@ -106,7 +96,6 @@ private boolean claim(Player player) { }); } - // Reveals every slot of today's draw but the last private void revealAllButLast() { int tasks = manager.tasks(uuid).tasks().size(); for (int slot = 0; slot < tasks - 1; slot++) { @@ -144,13 +133,10 @@ void paidOnceTheLastTaskIsRevealedAndNotBefore() { assertTrue(data().dailyRewardClaimed()); assertTrue(chat.get(0).contains("x1") && chat.get(0).contains("Diamond"), chat.toString()); - // Another open or click the same day pays nothing more assertFalse(claim(player)); assertEquals(1, paid.size()); } - // The claim is on disk before anything is handed over, so a crash - // mid-payout cannot pay it twice @Test void theClaimIsSavedBeforeThePayoutRuns() { revealAllButLast(); @@ -196,7 +182,6 @@ void theNextDayPaysAgain() { revealLast(); assertTrue(claim(player)); - // Back-date the row to a day it was claimed on; get() rolls it on PlayerData row = manager.getStore().peek(uuid); row.roll(row.weekKey(), "1999-01-01"); row.setDailyRewardClaimed(true); @@ -215,16 +200,10 @@ void noMatchingGroupIsNotMarkedAndCanStillBePaidLaterThatDay() { assertTrue(paid.isEmpty()); assertFalse(data().dailyRewardClaimed()); - // Joined the group since, and opens the GUI again assertTrue(claim(player(Set.of("group.vip")))); assertEquals(1, paid.size()); } - // ==================================== - // An operator (or a '*' holder whose wildcard does not set the node): - // hasPermission answers true for every node through its OP default, but - // none of the group nodes is actually set on him - // ==================================== @Test void anOperatorInNoGroupGetsNothing() { revealAllButLast(); @@ -235,7 +214,6 @@ void anOperatorInNoGroupGetsNothing() { assertFalse(data().dailyRewardClaimed()); } - // A node set to false - a negated group permission - is not membership @Test void aNodeSetToFalseIsNotMembership() { revealAllButLast(); @@ -262,11 +240,6 @@ void aFailedPayoutIsNotMarkedAndIsRetried() { assertTrue(data().dailyRewardClaimed()); } - // ==================================== - // An item that does not resolve is refused before the flag or the file is - // touched: every open reaches this, and must not cost a players.yml write. - // The warning is said once per path, however often it is hit. - // ==================================== @Test void anUnresolvableItemIsRefusedWithoutAnyWriteAndWarnedOnce() { List warnings = new ArrayList<>(); @@ -301,7 +274,6 @@ public void close() { assertEquals(1, warnings.stream().filter(line -> line.contains("Daily reward item 'DIAMOND'")).count(), warnings.toString()); - // Fixed by the operator: the next open pays resolves = true; assertTrue(claim(player)); assertEquals(1, paid.size()); @@ -310,8 +282,6 @@ public void close() { } } - // A players.yml that cannot be written pays nothing and leaves the claim - // for later @Test void aClaimThatCannotBeSavedPaysNothing() throws IOException { File notADirectory = new File(dir, "blocker"); @@ -350,11 +320,6 @@ void nothingIsPaidWhenNoGroupsAreConfigured() { assertFalse(data().dailyRewardClaimed()); } - // ==================================== - // A 'pool' group: one draw from rewards.pool, paid as written - no - // multiplier on the amounts or the chat line - and announced with the - // entry's display - // ==================================== @Test void aPoolGroupPaysOneDrawFromThePoolAtMultiplierOne() throws ReflectiveOperationException { RewardEntry gem = new RewardEntry(1, "&dA Gem", List.of(), List.of(new RewardEntry.Item("EMERALD", 2))); @@ -392,7 +357,6 @@ void aPoolGroupAndAnItemGroupEachPayTheirOwn() { assertEquals(gem, paid.get(1)); } - // Nothing to draw: nothing is saved or marked, and the next open retries @Test void aPoolGroupWithAnEmptyPoolPaysNothingAndStaysUnclaimed() { pool(List.of()); @@ -407,7 +371,6 @@ void aPoolGroupWithAnEmptyPoolPaysNothingAndStaysUnclaimed() { assertEquals(1, chat.size()); } - // The same rollback as a fixed item when the drawn entry hands nothing over @Test void aFailedPoolPayoutIsNotMarkedAndIsRetried() { pool(List.of(new RewardEntry(1, "A Gem", List.of(), List.of(new RewardEntry.Item("EMERALD", 1))))); @@ -428,10 +391,6 @@ private void pool(List pool) { TestManagers.pool(manager, ActivityConfiguration.DEFAULT_POOL, pool); } - // ==================================== - // Group matching on its own: the first listed group the player is in - // wins, whatever order his permissions come in - // ==================================== @Test void theFirstListedGroupWins() { Map groups = new LinkedHashMap<>(); diff --git a/src/test/java/tfmc/justin/activity/managers/ActivityManagerRerollTest.java b/src/test/java/tfmc/justin/activity/managers/ActivityManagerRerollTest.java index cfb59e5..051e097 100644 --- a/src/test/java/tfmc/justin/activity/managers/ActivityManagerRerollTest.java +++ b/src/test/java/tfmc/justin/activity/managers/ActivityManagerRerollTest.java @@ -16,19 +16,6 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; -// ==================================== -// ActivityManager.reroll(UUID), on a real manager built without a server (see -// TestManagers). rerollsPerDay defaults to 0 on a freshly built config (load() -// itself is never run headless), so every test here sets its own budget -// explicitly through TestManagers.rerollsPerDay - a test that forgot to would -// see DISABLED everywhere and fail loudly rather than silently pass. -// -// rerollMaxPoints is the other half of that rule, defaulted the other way -// round: TestManagers ships it at 1, the production value, so a test that -// banks points and forgets to widen the gate sees TOO_LATE rather than -// silently passing on a threshold no server runs. Anything that needs more -// headroom says so with TestManagers.rerollMaxPoints. -// ==================================== class ActivityManagerRerollTest { private static ActivityDef def(String id) { @@ -43,12 +30,6 @@ private static ActivityDef[] defs(int count) { return defs; } - // ==================================== - // A forced add (/activity add --force) never touches dailyPoints, so it - // neither trips the reroll.max-points gate nor gets handed back off the - // weekly bar by the reroll that follows it - only the points the player - // genuinely earned today are refunded. - // ==================================== @Test void aForcedAwardNeitherBlocksARerollNorIsRefundedByOne() { TestManagers.bukkit(); @@ -59,11 +40,9 @@ void aForcedAwardNeitherBlocksARerollNorIsRefundedByOne() { UUID uuid = UUID.randomUUID(); manager.tasks(uuid); - // 50 points on an activity capped at 10 a day and a budget of 10 assertEquals(50, manager.recordAdmin(uuid, "a0", 50, true).pointsAwarded()); PlayerData data = manager.getStore().get(uuid); assertEquals(50, data.points()); - // the gate reads dailyPoints, which a forced award leaves alone assertEquals(0, data.dailyPoints()); assertEquals(ActivityManager.Rerolled.DONE, manager.reroll(uuid)); @@ -102,10 +81,6 @@ void theRerollCounterIncrementsOnASuccessfulReroll() { assertEquals(1, manager.getStore().get(uuid).rerolls()); } - // ==================================== - // Once the budget is spent, NONE_LEFT must be a true no-op: not just the - // counter, every field a reroll would otherwise touch. - // ==================================== @Test void exhaustingTheBudgetReturnsNoneLeftAndMutatesNothing() { ActivityManager manager = TestManagers.manager(defs(20)); @@ -131,11 +106,6 @@ void exhaustingTheBudgetReturnsNoneLeftAndMutatesNothing() { assertEquals(revealedAfterFirst, data.revealed(), "NONE_LEFT changed revealed flags"); } - // ==================================== - // reroll.per-day: 0 refuses everyone before ever touching the store - the - // same shape as the DISABLED playtime.afk-minutes check, so no row is - // created for a player who never opened the GUI. - // ==================================== @Test void aZeroBudgetIsDisabledAndCreatesNoRow() { ActivityManager manager = TestManagers.manager(defs(20)); @@ -148,7 +118,6 @@ void aZeroBudgetIsDisabledAndCreatesNoRow() { assertNull(manager.getStore().peek(uuid), "a disabled reroll created a players.yml row"); } - // A disabled budget mutates nothing for a player who already has a draw @Test void aZeroBudgetMutatesNothingForAnExistingPlayer() { ActivityManager manager = TestManagers.manager(defs(20)); @@ -182,12 +151,6 @@ void aBudgetAboveOneAllowsExactlyThatManyPerDay() { assertEquals(3, manager.getStore().get(uuid).rerolls()); } - // ==================================== - // The reroll draw goes through PlayerData.draw the same way the normal - // draw does, so a 'daily-guaranteed' activity is still guaranteed - // afterwards - checked over enough repetitions to be meaningful, the same - // way ActivityManagerTasksTest checks the ordinary draw. - // ==================================== @Test void aGuaranteedActivitySurvivesARerollEveryTime() { for (int i = 0; i < 200; i++) { @@ -206,12 +169,6 @@ void aGuaranteedActivitySurvivesARerollEveryTime() { } } - // ==================================== - // players.yml was never read, so the store refuses every save: a reroll - // done here would cost the player today's points only until the next - // restart, and would hand the spent budget back with them. Refused whole, - // the same way claim() refuses a payout it cannot persist. - // ==================================== @Test void aStoreThatNeverLoadedRefusesTheRerollAndMutatesNothing() { ActivityManager manager = TestManagers.manager(defs(20)); @@ -226,15 +183,6 @@ void aStoreThatNeverLoadedRefusesTheRerollAndMutatesNothing() { assertEquals(0, data.rerolls(), "a refused reroll spent the budget"); } - // ==================================== - // Claim-then-reroll must not hand today's budget back for free: the - // claimed floor keeps the points on the bar, so dailyPoints carries - // forward instead of resetting to 0 (see PlayerData.reroll). - // - // A full day's earnings only reach the reroll at all on an admin who has - // raised reroll.max-points well above the shipped 1, so the gate is opened - // to 10 here rather than left at the default it would refuse under. - // ==================================== @Test void aRerollDoesNotRefundBudgetTheClaimedFloorKeptOnTheBar() { ActivityManager manager = TestManagers.manager(defs(20)); @@ -243,7 +191,6 @@ void aRerollDoesNotRefundBudgetTheClaimedFloorKeptOnTheBar() { TestManagers.rerollMaxPoints(manager, 10); UUID uuid = UUID.randomUUID(); PlayerData data = manager.tasks(uuid); - // Today's whole budget (dailyMax is 10 in TestManagers), then claimed data.record(10, def("a0"), 50, 10, List.of()); data.setClaimedPoints(10); assertEquals(10, data.points()); @@ -255,12 +202,6 @@ void aRerollDoesNotRefundBudgetTheClaimedFloorKeptOnTheBar() { assertEquals(10, data.dailyPoints(), "the reroll handed today's budget back for free"); } - // ==================================== - // The same floor at the shipped reroll.max-points: 1, the only threshold - // on which PlayerData.reroll's carry-forward is still reachable by a - // default server. One claimed point is all a player can have banked and - // still reroll, and it must stay banked. - // ==================================== @Test void theClaimedFloorStillBlocksTheRefundAtTheShippedThreshold() { ActivityManager manager = TestManagers.manager(defs(20)); @@ -280,8 +221,6 @@ void theClaimedFloorStillBlocksTheRefundAtTheShippedThreshold() { assertEquals(1, data.dailyPoints(), "the reroll handed today's budget back for free"); } - // A reroll called before the player ever opened the GUI still hands out a - // full, valid draw rather than working from an empty one @Test void aRerollWithNoPriorDrawStillProducesAFullDraw() { ActivityManager manager = TestManagers.manager(defs(20)); @@ -294,10 +233,6 @@ void aRerollWithNoPriorDrawStillProducesAFullDraw() { assertEquals(PlayerData.TASKS_PER_DAY, manager.getStore().get(uuid).tasks().size()); } - // ==================================== - // reroll.max-points is an inclusive ceiling: banked points equal to it - // still reroll, one more refuses. - // ==================================== @Test void dailyPointsExactlyAtTheThresholdStillRerolls() { ActivityManager manager = TestManagers.manager(defs(20)); @@ -336,15 +271,6 @@ void onePointAboveTheThresholdIsTooLateAndMutatesNothing() { assertEquals(0, data.rerolls(), "TOO_LATE spent the budget"); } - // ==================================== - // The gate reads dailyPoints after the rollover has been applied, so - // yesterday's earnings never block today's reroll. - // - // Midnight is modelled by backdating the row to the real previous day - // key rather than by inventing one: what makes the second click work is - // store.get rolling the row forward onto the key currentKeys() reports, - // so the test asserts the row landed on exactly that key. - // ==================================== @Test void aPlayerRefusedYesterdayMayRerollAfterTheDayRolls() { ActivityManager manager = TestManagers.manager(defs(20)); @@ -364,7 +290,6 @@ void aPlayerRefusedYesterdayMayRerollAfterTheDayRolls() { assertEquals(today, data.dayKey(), "the reroll ran without the day rolling forward"); } - // max-points: 0 - only a player who has earned nothing today may reroll @Test void aZeroThresholdAllowsOnlyAPlayerWhoHasEarnedNothing() { ActivityManager manager = TestManagers.manager(defs(20)); @@ -381,10 +306,6 @@ void aZeroThresholdAllowsOnlyAPlayerWhoHasEarnedNothing() { assertEquals(ActivityManager.Rerolled.TOO_LATE, manager.reroll(uuid)); } - // ==================================== - // Both blockers at once: the point gate wins. The threshold holds for the - // rest of the day whatever the budget says, so it is the useful answer. - // ==================================== @Test void theThresholdIsReportedBeforeAnExhaustedBudget() { ActivityManager manager = TestManagers.manager(defs(20)); diff --git a/src/test/java/tfmc/justin/activity/managers/ActivityManagerRewardItemsTest.java b/src/test/java/tfmc/justin/activity/managers/ActivityManagerRewardItemsTest.java index fad6e15..6a3685a 100644 --- a/src/test/java/tfmc/justin/activity/managers/ActivityManagerRewardItemsTest.java +++ b/src/test/java/tfmc/justin/activity/managers/ActivityManagerRewardItemsTest.java @@ -28,32 +28,10 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; -// ==================================== -// The item half of a reward payout: ActivityManager.giveItems, which takes the -// path resolver and the logger rather than reaching for TLibs and the plugin, -// so the handover can be driven headless. The player, his inventory and his -// world are Proxy stubs (the technique the listener tests use) that record -// what was added and what was dropped. -// -// Not reachable headless, and so not covered here: the real resolver -// (ActivityManager#resolveRewardItem) - both its TLibs branch, which needs -// TLibs, MMOItems and MythicLib running, and its Material branch, where -// new ItemStack(Material) needs the live registry of a running server, as does -// the Material#isItem() behind ActivityManager.isItem - so the guard that -// refuses a block-only name such as CARROTS only ever answers for real in -// production, and what is pinned here is its headless fallback; claim() itself, which -// needs a live store, a live Messages and Bukkit.dispatchCommand; and the real -// PlayerInventory#addItem stacking rules, which are server code. What the stubs -// stand in for is exactly those calls: addItem, getWorld, dropItemNaturally and -// updateInventory. Everything between them - resolution, the multiplier, the -// stack split, the leftover drop, the success rule - is the real code. -// ==================================== class ActivityManagerRewardItemsTest { private final List logged = new ArrayList<>(); - // The memo that keeps an unresolvable path from warning once per click is - // static, so each test starts from an empty one @BeforeEach void clearReportedPaths() { ActivityManager.reportedItemPaths.clear(); @@ -81,7 +59,6 @@ public void close() { return logger; } - // What one stub player was handed and what fell on the floor private static final class Handover { final List added = new ArrayList<>(); final List dropped = new ArrayList<>(); @@ -155,13 +132,6 @@ private static Handover player() { return handover; } - // ==================================== - // A stack that can exist headless: the real ItemStack constructors go - // through the Bukkit registry, which needs a running server, but the - // protected no-arg one only nulls a field. Type, amount and the item's own - // maximum stack size are held here and clone() is a fresh copy, which is - // all giveItems touches. - // ==================================== private static final class TestStack extends ItemStack { private final Material type; private final int maxStackSize; @@ -203,16 +173,11 @@ public TestStack clone() { } } - // The real resolver's bare-material branch, without the TLibs one: - // matchMaterial is a name lookup and runs headless, building the stack - // does not private static final Function MATERIALS = path -> { Material material = Material.matchMaterial(path); return material == null ? null : new TestStack(material, 1); }; - // The entry giveItems is handed: display matters only to the partial-payout - // warning, so every test that does not check it uses the same one private static RewardEntry entry(RewardEntry.Item... items) { return new RewardEntry(1, "Reward", List.of(), List.of(items)); } @@ -244,9 +209,6 @@ void aResolvedPathReachesTheInventoryWithTheConfiguredAmount() { assertTrue(handover.dropped.isEmpty()); } - // 'amount:' says how many to hand over, so it replaces whatever the - // resolved stack was built with rather than multiplying it - and it is set - // on a clone, so the resolver's own stack is untouched @Test void theAmountOverridesTheResolvedStackAndTheResolvedStackIsNotMutated() { Handover handover = player(); @@ -258,11 +220,6 @@ void theAmountOverridesTheResolvedStackAndTheResolvedStackIsNotMutated() { assertEquals(8, fromPath.getAmount()); } - // ==================================== - // rewards.multiplier, applied at payout rather than at load so a reload - // changes what the next claim pays - // ==================================== - @Test void theMultiplierMultipliesTheConfiguredAmount() { Handover handover = player(); @@ -282,11 +239,6 @@ void aMultiplierOfOneHandsOverExactlyTheConfiguredAmount() { assertEquals(3, handover.added.get(0).getAmount()); } - // ==================================== - // A total above one stack goes over as whole stacks of the resolved item's - // own maximum, never as one oversized slot - // ==================================== - @Test void anExactMultipleOfTheStackSizeIsSplitIntoWholeStacks() { Handover handover = player(); @@ -305,9 +257,6 @@ void aTotalWithARemainderEndsWithThePartStack() { assertEquals(List.of(64, 36), handover.added.stream().map(ItemStack::getAmount).toList()); } - // The finding this split exists for: 64 copies of an item whose stack size - // is 1 in a single slot is a dupe primitive, so the item's own limit is - // what the split uses - not the vanilla 64 @Test void anUnstackableItemIsHandedOverOneAtATime() { Handover handover = player(); @@ -318,7 +267,6 @@ void anUnstackableItemIsHandedOverOneAtATime() { assertEquals(List.of(1, 1, 1), handover.added.stream().map(ItemStack::getAmount).toList()); } - // Every stack of the split gets the same overflow treatment @Test void everyStackOfASplitDropsItsOwnOverflow() { Handover handover = player(); @@ -330,10 +278,6 @@ void everyStackOfASplitDropsItsOwnOverflow() { assertEquals(3, handover.dropped.size()); } - // TLibs down, or the MMOItems id deleted since load: nothing was handed - // over, so claim() must see a failure and roll the milestone back - and it - // must get there by the "could not be resolved" route, not by something - // throwing on the way @Test void aPathThatDoesNotResolveAtPayoutHandsNothingOver() { Handover handover = player(); @@ -346,9 +290,6 @@ void aPathThatDoesNotResolveAtPayoutHandsNothingOver() { assertFalse(loggedAtLeastOne(Level.SEVERE, "threw for")); } - // Same rule for an ItemsAdder path: ItemsAdder down, or the id removed - // from the pack since load, must leave the milestone unclaimed rather than - // burn it for nothing @Test void anItemsAdderPathThatDoesNotResolveAtPayoutHandsNothingOver() { Handover handover = player(); @@ -362,8 +303,6 @@ void anItemsAdderPathThatDoesNotResolveAtPayoutHandsNothingOver() { assertFalse(loggedAtLeastOne(Level.SEVERE, "threw for")); } - // A claim can be repeated at click rate, so a path that cannot resolve is - // reported once rather than once per click @Test void anUnresolvablePathIsReportedOnlyOnce() { Handover handover = player(); @@ -375,8 +314,6 @@ void anUnresolvablePathIsReportedOnlyOnce() { .count()); } - // The amount and the milestone are what an operator needs to re-issue the - // reward by hand @Test void theUnresolvedWarningNamesTheTotalAndTheMilestone() { Handover handover = player(); @@ -396,9 +333,6 @@ void anAirStackCountsAsUnresolvedRatherThanAsAReward() { assertTrue(handover.added.isEmpty()); } - // A resolver that throws must not unwind out of the payout: claim() has - // already burned and saved the milestones by then. Nothing reached the - // inventory, so the milestone is still refused. @Test void aThrowingResolverIsSwallowedAndCountsAsNothingHandedOver() { Handover handover = player(); @@ -411,12 +345,6 @@ void aThrowingResolverIsSwallowedAndCountsAsNothingHandedOver() { assertTrue(loggedAtLeastOne(Level.SEVERE, "threw for")); } - // ==================================== - // addItem is the point of no return: it can throw having already filled - // some slots, so the entry counts as paid the moment the inventory is - // touched. Counting it unpaid would hand the milestone back and let the - // next click pay those same items a second time. - // ==================================== @Test void aThrowingAddItemStillCountsAsPaidBecauseTheInventoryWasTouched() { Handover handover = player(); @@ -426,9 +354,6 @@ void aThrowingAddItemStillCountsAsPaidBecauseTheInventoryWasTouched() { assertTrue(loggedAtLeastOne(Level.SEVERE, "threw for")); } - // One bad path among good ones costs only itself: the rest is still paid, - // and the entry counts as paid, so re-drawing it cannot duplicate them. - // An operator gets one line naming the entry, since the milestone is gone. @Test void oneUnresolvablePathAmongGoodOnesStillCountsAsPaidForTheRest() { Handover handover = player(); @@ -454,11 +379,6 @@ void whatDoesNotFitIsDroppedAtThePlayersFeetRatherThanLost() { assertEquals(List.of(overflow), handover.dropped); } - // ==================================== - // The drop happens after the entry is already counted as paid, on purpose: - // a drop that throws must not un-pay a milestone whose items are already - // in the inventory. - // ==================================== @Test void aThrowingDropStillLeavesTheEntryPaid() { Handover handover = player(); @@ -468,8 +388,6 @@ void aThrowingDropStillLeavesTheEntryPaid() { assertTrue(give(handover, MATERIALS, new RewardEntry.Item("DIAMOND", 3))); } - // The inventory is mutated inside a cancelled InventoryClickEvent, so the - // client needs telling or it shows ghost stacks until the window reopens @Test void theClientIsResyncedOnceAfterAHandover() { Handover handover = player(); @@ -479,18 +397,12 @@ void theClientIsResyncedOnceAfterAHandover() { assertEquals(1, handover.updates); } - // Material#isItem() reads a registry that only exists on a running server. - // Without one the resolved stack is taken at face value: refusing every - // stack instead would mean handing nothing over at all. @Test void theBlockOnlyGuardPassesAnythingThroughWithoutARegistry() { assertTrue(ActivityManager.isItem(new TestStack(Material.DIAMOND, 1))); assertTrue(ActivityManager.isItem(new TestStack(Material.CARROTS, 1))); } - // The resync runs after the milestone is already burned and saved, so it - // must not unwind past claim()'s rollback: an escape there would leave disk - // claiming milestones the player was never paid for. @Test void aThrowingResyncIsSwallowedAndLeavesTheEntryPaid() { Handover handover = player(); @@ -502,9 +414,6 @@ void aThrowingResyncIsSwallowedAndLeavesTheEntryPaid() { assertTrue(loggedAtLeastOne(Level.WARNING, "Could not resync the inventory")); } - // The entry counts as paid the moment the inventory is touched, which is - // before addItem returns - so "only part of it reached him" must not be - // claimed when the first insert threw and nothing reached him at all @Test void aThrowingFirstInsertIsNotReportedAsAPartialHandover() { Handover handover = player(); @@ -527,12 +436,6 @@ void anEntryWithNoItemsHandsNothingOver() { assertEquals(0, handover.updates); } - // ==================================== - // The combine that decides whether a drawn entry counts as paid. This is - // the whole duplicate-prevention rule for a mixed entry: an entry whose - // items went out but whose commands failed must stay paid, or the next - // click draws it again and hands those items over twice. - // ==================================== @Test void anEntryCountsAsPaidIfEitherHalfSucceeded() { assertTrue(ActivityManager.dispatchRewards(() -> true, () -> false)); @@ -541,8 +444,6 @@ void anEntryCountsAsPaidIfEitherHalfSucceeded() { assertFalse(ActivityManager.dispatchRewards(() -> false, () -> false)); } - // Both halves always run: the commands are not skipped just because the - // items already succeeded @Test void bothHalvesOfAnEntryAlwaysRun() { boolean[] ran = new boolean[2]; @@ -559,10 +460,6 @@ void bothHalvesOfAnEntryAlwaysRun() { assertTrue(ran[1]); } - // ==================================== - // runnableEntries with items in the pool: an item never has a name pasted - // into it, so it pays a Bedrock/unsafe name that no %player% command can. - // ==================================== @Test void anItemEntryIsRunnableForANameNoCommandCanBePaidFor() { RewardEntry playerOnly = new RewardEntry(1, "player-only", @@ -589,13 +486,6 @@ void theWeightedDrawStillWalksAPoolOfMixedEntries() { assertEquals(items, RewardEntry.pick(pool, 3)); } - // ==================================== - // The two gates are independent by design: ItemsAdder is not part of the - // TLibs/MMOItems/MythicLib trio itemPathsUsable() stands for, so each path - // form must resolve on its own backing plugin alone. All four combinations - // are pinned because the regression this guards against is an '&&' - // between them, which only shows on a server missing one of the two. - // ==================================== private static final Function TLIBS_ITEMS = path -> new TestStack(Material.IRON_INGOT, 1); private static final Function IA_ITEMS = id -> new TestStack(Material.DIAMOND, 1); @@ -620,18 +510,11 @@ void aTLibsRewardPathNeedsOnlyTheItemPathsGate() { assertNull(resolveReward("m.material.steel", false, false)); } - // A malformed ia. path is never handed to the hook, gate open or not @Test void aMalformedItemsAdderRewardPathResolvesToNothing() { assertNull(resolveReward("ia.saucepan", true, true)); } - // ==================================== - // rewards.drops: a milestone with a fixed drop pays that entry and never - // draws from the pool, and says in chat what it actually hands over. The - // fixed entry is built the way loadMilestoneDrops builds it: its display - // is the bare item name. - // ==================================== private static final RewardEntry DIAMONDS = new RewardEntry(1, "Diamond", List.of(), List.of(new RewardEntry.Item("DIAMOND", 3))); private static final RewardEntry POOLED = new RewardEntry(1, "pooled", List.of("say hi"), List.of()); @@ -640,8 +523,6 @@ void aMalformedItemsAdderRewardPathResolvesToNothing() { throw new AssertionError("a fixed drop drew from the pool"); }; - // The pool lookup rewardFor() and payMilestones() take: one pool under - // every name, which is what every case here but the named-pool ones needs private static Function> pools(RewardEntry... entries) { List pool = List.of(entries); return name -> pool; @@ -669,7 +550,6 @@ void aPoolMilestoneStillDrawsFromThePool() { assertEquals(List.of(List.of(POOLED)), drawnFrom); } - // 'DIAMOND 3' at rewards.multiplier 2 hands over six, so chat says six @Test void aFixedDropsChatLineCarriesTheMultipliedAmount() { assertEquals("#50d990x3 #b8906eDiamond", @@ -678,8 +558,6 @@ void aFixedDropsChatLineCarriesTheMultipliedAmount() { ActivityManager.rewardFor(10, Map.of(10, DIAMONDS), 2, pools(), NO_DRAW).get(0).display()); } - // A fixed drop has nothing to spin: one payout, its amount left as - // written for giveItems to multiply (see theMultiplierMultipliesTheConfiguredAmount) @Test void aFixedDropIsOnePayoutAtAnyMultiplier() { List spins = ActivityManager.rewardFor(10, Map.of(10, DIAMONDS), 3, pools(POOLED), NO_DRAW); @@ -689,10 +567,6 @@ void aFixedDropIsOnePayoutAtAnyMultiplier() { assertEquals("#50d990x9 #b8906eDiamond", spins.get(0).display()); } - // ==================================== - // rewards.multiplier on a pool milestone is the number of spins: that - // many independent draws, each paid at face value - // ==================================== private static final RewardEntry STEEL_PICK = new RewardEntry(1, "Steel Pickaxe", List.of("say pick"), List.of(new RewardEntry.Item("m.tool.steel_pickaxe", 1))); @@ -723,8 +597,6 @@ void aMultiplierOfOneSpinsThePoolOnce() { assertEquals(1, draws[0]); } - // With replacement: one entry can win every spin, and each spin is the - // entry as configured, not a multiplied copy @Test void spinsCanRepeatAnEntryAtItsOwnAmount() { List spins = ActivityManager.rewardFor(20, Map.of(), 2, pools(DIAMONDS, POOLED), @@ -735,8 +607,6 @@ void spinsCanRepeatAnEntryAtItsOwnAmount() { assertEquals("Diamond", spins.get(1).display()); } - // Whether claim() needs a usable pool at all: only when a due milestone - // has no fixed drop @Test void everyDueMilestoneFixedNeedsNoPool() { assertFalse(ActivityManager.needsPool(List.of(10, 20), Map.of(10, DIAMONDS, 20, DIAMONDS))); @@ -752,11 +622,6 @@ void noDropsAtAllNeedsThePool() { assertTrue(ActivityManager.needsPool(List.of(10), Map.of())); } - // ==================================== - // claim()'s payout loop: what each spin is paid at, and what one failed - // spin costs. The pay function records every call and answers from a - // script, so a failure can be put on any spin. - // ==================================== private record PaidSpin(int milestone, RewardEntry entry, int multiplier) {} private static final Function, RewardEntry> FIRST = p -> p.get(0); @@ -807,7 +672,6 @@ void aFailedSpinDoesNotStopTheOthers() { + "'milestone 20' lines above for what failed.")); } - // Not memoised: every partial milestone is logged @Test void everyPartialMilestoneIsLogged() { payMilestones(List.of(20), Map.of(), 2, new ArrayList<>(), false, true); @@ -823,14 +687,10 @@ void aMilestoneWhoseSpinsAllFailIsNotCounted() { ActivityManager.Payout payout = payMilestones(List.of(20, 30), Map.of(), 2, calls, false, false); assertEquals(new ActivityManager.Payout(0, true), payout); - // Milestone 30 is never spun: it stays claimable with 20 assertEquals(2, calls.size()); assertFalse(loggedAtLeastOne(Level.SEVERE, "spins for")); } - // A milestone that only partly pays stops the loop before the next - // milestone is even attempted, and rollbackClaimedPoints keeps the - // partial one claimed while rolling the untouched one back. @Test void aPartialMilestoneStopsBeforeTheNextOneAndStaysClaimed() { List calls = new ArrayList<>(); @@ -845,8 +705,6 @@ void aPartialMilestoneStopsBeforeTheNextOneAndStaysClaimed() { assertEquals(20, ActivityManager.rollbackClaimedPoints(10, payout.paid(), List.of(20, 30))); } - // The log names what was being paid, so a daily reward does not read as - // a milestone @Test void theLogNamesTheDailyRewardRatherThanAMilestone() { Handover handover = player(); diff --git a/src/test/java/tfmc/justin/activity/managers/ActivityManagerTasksTest.java b/src/test/java/tfmc/justin/activity/managers/ActivityManagerTasksTest.java index dae4626..160652e 100644 --- a/src/test/java/tfmc/justin/activity/managers/ActivityManagerTasksTest.java +++ b/src/test/java/tfmc/justin/activity/managers/ActivityManagerTasksTest.java @@ -18,12 +18,6 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; -// ==================================== -// The daily-task gate, on a real manager built without a server (see -// TestManagers). Every activity here is 'every: 2', so a single recorded -// action never awards a point - which keeps recordAction away from -// Bukkit.getPlayer(), unreachable headless. The witness is the action count. -// ==================================== class ActivityManagerTasksTest { private static ActivityDef def(String id) { @@ -149,7 +143,6 @@ void anEmptySlotCannotBeRevealed() { assertNull(manager.reveal(uuid, -1).revealedId()); } - // /activity add is the admin's testing aid and is not gated @Test void theAdminAddBypassesTheGate() { ActivityManager manager = TestManagers.manager(defs(20)); @@ -174,10 +167,6 @@ void anUnknownActivityIsStillRejected() { assertFalse(manager.isTracked(uuid, "nope")); } - // ==================================== - // No draw is made off the record path: a listener event for a player who - // has not opened the GUI today must leave no row behind in players.yml. - // ==================================== @Test void aListenerEventForAPlayerWithNoDrawCreatesNoRow() { ActivityManager manager = TestManagers.manager(defs(20)); @@ -198,7 +187,6 @@ void isTrackedIsFalseAndCreatesNoRowBeforeTheFirstDraw() { assertNull(manager.getStore().peek(uuid), "isTracked created a players.yml row"); } - // The GUI paths are the ones allowed to draw, and only they create the row @Test void openingTheGuiIsWhatDraws() { ActivityManager manager = TestManagers.manager(defs(20)); @@ -208,12 +196,6 @@ void openingTheGuiIsWhatDraws() { assertNotNull(manager.getStore().peek(uuid)); } - // ==================================== - // A reload that drops one of the drawn activities compacts the draw and - // tops it back up, so the slot the player clicked is no longer the one - // they saw. reveal() has to say so - the GUI repaints every task slot on - // it rather than just the clicked one. - // ==================================== @Test void aDrawChangedMidClickIsReported() { ActivityManager manager = TestManagers.manager(defs(20)); @@ -227,19 +209,12 @@ void aDrawChangedMidClickIsReported() { List tasks = manager.tasks(uuid).tasks(); assertEquals(PlayerData.TASKS_PER_DAY, tasks.size()); assertFalse(tasks.contains(dropped)); - // The clicked id is gone, so nothing was revealed - the slot now holds - // a task the player never asked about assertNull(reveal.revealedId()); for (String task : tasks) { assertFalse(manager.getStore().get(uuid).isRevealed(task), task + " was revealed by proxy"); } } - // ==================================== - // The id is what is revealed, not the index: a reload that dropped an - // earlier task compacts the draw, so the clicked id has moved down a slot - // by the time the reveal lands. - // ==================================== @Test void aCompactedDrawStillRevealsTheClickedTask() { ActivityManager manager = TestManagers.manager(defs(20)); @@ -255,11 +230,6 @@ void aCompactedDrawStillRevealsTheClickedTask() { assertTrue(manager.getStore().get(uuid).isRevealed(clicked)); } - // ==================================== - // A 'daily-guaranteed' activity is in every player's draw, in a slot the - // shuffle picks - the config warning, not the draw, is what deals with - // more guaranteed activities than there are slots. - // ==================================== @Test void aGuaranteedActivityIsInEveryDrawAtAVaryingSlot() { ActivityManager manager = TestManagers.manager(defs(20)); @@ -277,8 +247,6 @@ void aGuaranteedActivityIsInEveryDrawAtAVaryingSlot() { assertTrue(slots.size() > 1, "the guaranteed activity always landed in slot " + slots); } - // A reload that drops the guaranteed activity itself: it is simply not - // drawable any more, and the draw is still full @Test void anUnloadedGuaranteedActivityIsNotDrawn() { ActivityManager manager = TestManagers.manager(defs(20)); @@ -291,10 +259,6 @@ void anUnloadedGuaranteedActivityIsNotDrawn() { assertFalse(tasks.contains("a3")); } - // ==================================== - // Marking an activity guaranteed after a player already has today's draw - // must not leave them without it for the rest of the day - // ==================================== @Test void anExistingDrawGainsAGuaranteedActivityOnItsNextUse() { ActivityManager manager = TestManagers.manager(defs(20)); diff --git a/src/test/java/tfmc/justin/activity/managers/ActivityManagerTest.java b/src/test/java/tfmc/justin/activity/managers/ActivityManagerTest.java index 63ee286..a66eba4 100644 --- a/src/test/java/tfmc/justin/activity/managers/ActivityManagerTest.java +++ b/src/test/java/tfmc/justin/activity/managers/ActivityManagerTest.java @@ -12,7 +12,6 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; -// Only the pure half of the manager: the rest needs a running server class ActivityManagerTest { @Test @@ -74,11 +73,6 @@ void aNullCommandRunsNothing() { assertFalse(ActivityManager.canRunCommand(null, "Notch")); } - // ==================================== - // runnableEntries: the pool narrowed to what this player's name can - // actually be paid from. An entry survives if at least one of its - // commands can run, even if the rest of its commands cannot. - // ==================================== @Test void unsafeNameKeepsOnlyEntriesWithAUuidOrNoPlaceholderCommand() { RewardEntry playerOnly = new RewardEntry(1, "player-only", @@ -112,15 +106,10 @@ void anEmptyPoolStaysEmpty() { assertEquals(List.of(), ActivityManager.runnableEntries(List.of(), "Bedrock Player")); } - // ==================================== - // The claim arithmetic. A full claim burns every milestone the bar has - // reached; a partial one gives back only what never went out. - // ==================================== @Test void aPartialRollbackKeepsOnlyTheMilestonesThatPaid() { List due = List.of(20, 30, 40); - // Claimed 10 before, 3 milestones were due, 1 went out assertEquals(20, ActivityManager.rollbackClaimedPoints(10, 1, due)); assertEquals(30, ActivityManager.rollbackClaimedPoints(10, 2, due)); } @@ -147,14 +136,6 @@ void payingEverythingRollsForwardToTheLastDueMilestone() { assertEquals(40, ActivityManager.rollbackClaimedPoints(10, due.size(), due)); } - // ==================================== - // The two bounds that make a partial payout safe, over every shape the - // numbers can take: - // - // never below claimedBefore - a failure cannot hand back a milestone - // that was paid before this click - // always below a full claim - what did not go out stays claimable - // ==================================== @Test void aPartialRollbackStaysBetweenWhereItStartedAndAFullClaim() { for (int points : new int[] {0, 7, 10, 19, 20, 45}) { @@ -179,11 +160,6 @@ void aPartialRollbackStaysBetweenWhereItStartedAndAFullClaim() { } } - // ==================================== - // The weighted draw. A roll is an index into the cumulative weights, so - // the boundaries are what matter: the last roll of one entry and the first - // of the next. - // ==================================== @Test void aWeightedDrawWalksTheCumulativeWeights() { RewardEntry common = new RewardEntry(3, "common", List.of("give %player% diamond 3"), List.of()); @@ -201,12 +177,6 @@ void anEmptyPoolDrawsNothing() { assertNull(RewardEntry.pick(List.of(), 0)); } - // ==================================== - // The idle threshold the playtime timer skips a player on. 0 and below are - // the admin switching the check off; a zero-length Duration would instead - // be met by everyone every minute and pay nobody at all. - // ==================================== - @Test void aPositiveAfkSettingIsThatManyMinutes() { assertEquals(Duration.ofMinutes(5), ActivityManager.afkThreshold(5)); diff --git a/src/test/java/tfmc/justin/activity/managers/TestManagers.java b/src/test/java/tfmc/justin/activity/managers/TestManagers.java index 8d31a24..6b8c16c 100644 --- a/src/test/java/tfmc/justin/activity/managers/TestManagers.java +++ b/src/test/java/tfmc/justin/activity/managers/TestManagers.java @@ -24,19 +24,6 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.logging.Logger; -// ==================================== -// A real ActivityManager (and with it a real ActivityConfiguration and -// PlayerStore) without a running server: load() is skipped and the handful of -// config fields the record path reads are set directly. -// -// JavaPlugin's constructor throws unless its classloader is a live -// PluginClassLoader, so one is allocated through ReflectionFactory the same -// way ActivityConfigurationStationTest does, then handed a real Logger. -// -// The store is left "not loaded" on purpose: nothing here writes a file. The -// paths that refuse to work unsaved (claim, reroll) opt back in with -// storeLoaded(). -// ==================================== public final class TestManagers { private TestManagers() { @@ -64,9 +51,6 @@ public static ActivityManager manager(ActivityDef... defs) { set(config, "barMax", 50); set(config, "dailyMax", 10); set(config, "milestones", List.of(10, 20)); - // The shipped reroll.max-points, so a test that forgets to set its - // own gate runs the configuration players actually get rather than - // a permissive one no server has set(config, "rerollMaxPoints", 1); return manager; @@ -75,8 +59,6 @@ public static ActivityManager manager(ActivityDef... defs) { } } - // Marks activities 'daily-guaranteed', the same way load() would - the - // draw is lazy, so this counts as long as it happens before the first one public static void guarantee(ActivityManager manager, String... ids) { try { set(manager.getConfiguration(), "guaranteedActivities", List.of(ids)); @@ -85,11 +67,6 @@ public static void guarantee(ActivityManager manager, String... ids) { } } - // ==================================== - // What a reload dropping an activity leaves behind: the loaded set minus - // one id, installed the same way manager() installs it. Here rather than - // in a test, so the field name lives in exactly one place. - // ==================================== public static void unload(ActivityManager manager, String id) { ActivityConfiguration config = manager.getConfiguration(); Map remaining = new LinkedHashMap<>(); @@ -105,11 +82,6 @@ public static void unload(ActivityManager manager, String id) { } } - // ==================================== - // A spent budget or a full bar without a server to award the points on: - // headless, anything that actually credits a point reaches - // Bukkit.getPlayer, so a cap is set up by lowering the limit instead. - // ==================================== public static void limits(ActivityManager manager, int barMax, int dailyMax) { try { set(manager.getConfiguration(), "barMax", barMax); @@ -119,8 +91,6 @@ public static void limits(ActivityManager manager, int barMax, int dailyMax) { } } - // The reroll budget load() would parse from reroll.per-day - set directly - // the same way every other numeric config field here is public static void rerollsPerDay(ActivityManager manager, int perDay) { try { set(manager.getConfiguration(), "rerollsPerDay", perDay); @@ -129,7 +99,6 @@ public static void rerollsPerDay(ActivityManager manager, int perDay) { } } - // The point gate load() would parse from reroll.max-points public static void rerollMaxPoints(ActivityManager manager, int maxPoints) { try { set(manager.getConfiguration(), "rerollMaxPoints", maxPoints); @@ -138,11 +107,6 @@ public static void rerollMaxPoints(ActivityManager manager, int maxPoints) { } } - // ==================================== - // Marks players.yml as having been read, which load() would do. Only - // needed by the paths that refuse outright while nothing can be saved; - // still nothing is ever written, since no test calls save(). - // ==================================== public static void storeLoaded(ActivityManager manager) { try { set(manager.getStore(), "loaded", true); @@ -151,10 +115,6 @@ public static void storeLoaded(ActivityManager manager) { } } - // ==================================== - // Points players.yml at a file of the test's choosing, for the paths that - // save before they pay (the daily reward). Pair with storeLoaded(). - // ==================================== public static void storeFile(ActivityManager manager, File file) { try { set(manager.getStore(), "file", file); @@ -163,10 +123,6 @@ public static void storeFile(ActivityManager manager, File file) { } } - // ==================================== - // A reward pool as load() would leave it: 'pool' is the default one, - // 'pool_' a named one. Merges, so a test can install several. - // ==================================== public static void pool(ActivityManager manager, String name, List entries) { try { ActivityConfiguration config = manager.getConfiguration(); @@ -182,7 +138,6 @@ public static void pool(ActivityManager manager, String name, List } } - // daily-reward.groups as load() would leave it: group -> reward, in order public static void dailyRewards(ActivityManager manager, Map groups) { try { set(manager.getConfiguration(), "dailyRewards", groups); @@ -191,11 +146,6 @@ public static void dailyRewards(ActivityManager manager, Map permissions, List chat) { InvocationHandler handler = (proxy, method, args) -> switch (method.getName()) { case "getUniqueId" -> uuid; @@ -211,12 +161,6 @@ public static Player player(UUID uuid, Set permissions, List cha TestManagers.class.getClassLoader(), new Class[] {Player.class}, handler); } - // ==================================== - // The shipped messages.yml, installed straight off disk. Messages.reload() - // wants a data folder and a running server, and every reply the admin - // command sends goes through it - so the text tested here is the text - // players actually get. - // ==================================== public static void messages(ActivityManager manager) { try { set(manager.getConfiguration().messages(), "messages", @@ -226,20 +170,6 @@ public static void messages(ActivityManager manager) { } } - // ==================================== - // A Bukkit server stub, so the record paths that actually credit a point - // can be driven headless: they end in Bukkit.getPlayer(uuid), which NPEs - // while Bukkit.server is null. The stub answers that with null - the - // offline-player case - so the award lands and nothing is announced. - // Bukkit's singleton can only be set once per JVM, so the contract is - // first caller wins: whichever test calls this first installs the stub - // every later test in the same fork gets. That is fine while this is the - // only stub - and if another one is ever installed, or this is reached - // inside a real server, the call throws rather than silently handing back - // a server the caller did not ask for. Every method the stub does not - // answer throws too, so a test that needs a richer Server fails loudly on - // the call it makes rather than quietly getting a wrong answer. - // ==================================== public static void bukkit() { Server installed = Bukkit.getServer(); if (installed != null) { @@ -251,10 +181,6 @@ public static void bukkit() { } InvocationHandler handler = (proxy, method, args) -> switch (method.getName()) { case "getPlayer" -> args[0] instanceof UUID uuid ? ONLINE.get(uuid) : null; - // Its own logger, deliberately not the one audit lines land on: - // anything logged through Bukkit.getLogger() must not show up in - // a test's captured audit stream. Hyphen in the name prevents Java's - // hierarchical logger inheritance (dot would make it a child of TestManagers) case "getLogger" -> Logger.getLogger(SERVER_LOGGER_NAME); case "getName", "getVersion", "getBukkitVersion" -> "TestManagers"; case "toString" -> STUB_NAME; @@ -265,9 +191,6 @@ public static void bukkit() { Server server = (Server) Proxy.newProxyInstance( TestManagers.class.getClassLoader(), new Class[] {Server.class}, handler); try { - // Not Bukkit.setServer: it logs a version banner built from - // ServerBuildInfo, which has no provider outside a real server and - // throws NoSuchElementException - after the field is already set Field field = Bukkit.class.getDeclaredField("server"); field.setAccessible(true); field.set(null, server); @@ -276,11 +199,6 @@ public static void bukkit() { } } - // ==================================== - // A player the stub's getPlayer(UUID) answers with, so a test can see - // what an award announces. Cleared by offline(); every other uuid is - // still the offline-player case. - // ==================================== private static final Map ONLINE = new ConcurrentHashMap<>(); public static void online(Player player) { @@ -291,8 +209,6 @@ public static void offline(UUID uuid) { ONLINE.remove(uuid); } - // The logger the stub plugin hands out, which is what an audit line lands - // in - a test captures it by attaching a Handler here public static Logger logger() { return Logger.getLogger(LOGGER_NAME); } diff --git a/src/test/java/tfmc/justin/activity/models/ActivityDefTest.java b/src/test/java/tfmc/justin/activity/models/ActivityDefTest.java index 3dd4862..d0dd91a 100644 --- a/src/test/java/tfmc/justin/activity/models/ActivityDefTest.java +++ b/src/test/java/tfmc/justin/activity/models/ActivityDefTest.java @@ -29,8 +29,6 @@ void theFullConstructorRoundTripsIconPath() { assertEquals("m.item.ballot", def.iconPath()); } - // daily-cap counts awards, not points: a day is worth min(count / every, - // dailyCap) * points @Test void dailyCapCountsAwardsNotPoints() { ActivityDef def = new ActivityDef("injured", "Injured", Material.BONE, null, 1, 5, 1); @@ -67,9 +65,6 @@ void capPointsAndWorthSaturateInsteadOfOverflowing() { assertEquals(1_000_000, def.worth(1)); } - // The GUI's "Today" line counts payouts, not points: every 1, points 5, - // daily-cap 1 reads 0/1 before an action and 1/1 after - never "0/5" or - // "5/5", which players misread as "do it five times" @Test void completionsCountPayoutsNotPoints() { ActivityDef def = new ActivityDef("injured", "Injured", Material.BONE, null, 1, 5, 1); diff --git a/src/test/java/tfmc/justin/activity/models/PlayerDataTest.java b/src/test/java/tfmc/justin/activity/models/PlayerDataTest.java index 70e2763..3bce94a 100644 --- a/src/test/java/tfmc/justin/activity/models/PlayerDataTest.java +++ b/src/test/java/tfmc/justin/activity/models/PlayerDataTest.java @@ -36,12 +36,6 @@ private RecordResult record(PlayerData data, ActivityDef def, int amount) { return data.record(amount, def, MAX, DAILY_MAX, MILESTONES); } - // ==================================== - // /activity add --force: the same count, none of the daily limits. VOTE is - // every: 1, points: 1, daily-cap: 5 - the shipped vote activity - and the - // budget handed in is deliberately smaller than the award, since a forced - // add is not supposed to look at it at all. - // ==================================== private RecordResult forced(PlayerData data, ActivityDef def, int amount, int max) { return data.recordForced(amount, def, max, MILESTONES); } @@ -56,9 +50,6 @@ void aForcedRecordIgnoresTheActivityCapAndTodaysBudget() { assertEquals(Recorded.RECORDED, result.outcome()); assertEquals(50, data.points()); assertEquals(50, data.count("vote")); - // Nothing of a forced award is spent out of today's budget, so - // dailyPoints can never be pushed past the bar.daily-max the loader - // clamps it to assertEquals(0, data.dailyPoints()); } @@ -72,16 +63,13 @@ void aForcedRecordStillStopsAtTheWeeklyMaximumAndSaysSo() { assertEquals(Recorded.WEEKLY_CLAMPED, clamped.outcome()); assertEquals(MAX, data.points()); - // ...and once the bar is full there is nothing left to clamp RecordResult full = forced(data, VOTE, 10, MAX); assertEquals(0, full.pointsAwarded()); assertEquals(Recorded.WEEKLY_MAX, full.outcome()); assertEquals(MAX, data.points()); - // the count still went in assertEquals(60, data.count("vote")); } - // A count part-way to its next award is a plain success, not a cap @Test void aForcedRecordPartWayToThePointIsStillRecorded() { PlayerData data = data(); @@ -93,13 +81,6 @@ void aForcedRecordPartWayToThePointIsStillRecorded() { assertEquals(5, data.count("instrument")); } - // ==================================== - // A forced award that saturates at Integer.MAX_VALUE must fill the bar, - // not wrap it. 'points + p' as an int would go negative and be clamped to - // 0, wiping a bar that already had points on it - and leaving points below - // claimedPoints, which PlayerStore.parse resolves by cutting claimedPoints - // down, handing every milestone the player already collected back. - // ==================================== @Test void aForcedAwardThatSaturatesFillsTheBarInsteadOfWrappingIt() { ActivityDef rich = new ActivityDef("boss", "Boss", Material.STONE, null, 1, 2500, 0); @@ -123,12 +104,6 @@ void aForcedRecordStillCountsMilestonesReached() { assertEquals(MILESTONES.size(), forced(data, VOTE, 50, MAX).milestonesReached()); } - // ==================================== - // Because a forced award never touches dailyPoints, a later reroll hands - // back only what the player genuinely earned today - the staff-granted - // points stay on the bar - and the reroll.max-points gate (which reads - // dailyPoints) is not tripped by a forced add either. - // ==================================== @Test void aForcedAwardIsNotRefundedByAReroll() { PlayerData data = data(); @@ -240,8 +215,6 @@ void claimingRemovesFromClaimable() { assertEquals(1, data.claimable(MILESTONES)); } - // A paid milestone stays paid: adding milestones below it must not make - // what was already handed over claimable again @Test void addingLowerMilestonesDoesNotReviveAPaidClaim() { PlayerData data = data(); @@ -346,7 +319,6 @@ void claimableCountsOnlyTheMilestonesReachedAndNotYetPaid() { record(data, UNCAPPED, 15); data.setClaimedPoints(10); - // 15 has reached 5, 10 and 15; 5 and 10 are already paid for assertEquals(1, data.claimable(List.of(5, 10, 15, 20))); } @@ -354,7 +326,6 @@ void claimableCountsOnlyTheMilestonesReachedAndNotYetPaid() { void droppingMilestonesAfterAFullClaimDoesNotReviveIt() { PlayerData data = data(); record(data, UNCAPPED, MAX); - // Claimed everything: the highest milestone on the bar is burned data.setClaimedPoints(MAX); assertEquals(0, data.claimable(List.of(20))); @@ -366,7 +337,6 @@ void milestonesBelowAFullClaimAreNotReviveable() { record(data, UNCAPPED, MAX); data.setClaimedPoints(MAX); - // Every one of them is at or under what was already paid for assertEquals(0, data.claimable(List.of(7, 14))); } @@ -377,20 +347,13 @@ void changingMilestonesStillExposesAReachableUnclaimedOne() { data.setClaimedPoints(10); record(data, UNCAPPED, 7); - // points=17, claimed=10: 14 has been reached and never paid for assertEquals(1, data.claimable(List.of(7, 14))); } - // ==================================== - // claimable() is what is left unpaid, not everything the bar has reached: - // a player who has already been paid for part of the bar must be owed only - // the rest, or every claim would hand out the whole bar again. - // ==================================== @Test void partlyClaimedPointsOnlyOweTheRemainder() { PlayerData data = new PlayerData(20, 0, WEEK, DAY, 10, java.util.Map.of()); - // 15 and 20 are left; 5 and 10 were paid for assertEquals(2, data.claimable(List.of(5, 10, 15, 20))); } @@ -398,7 +361,6 @@ void partlyClaimedPointsOnlyOweTheRemainder() { void pointsShortOfAMilestoneDoNotOweIt() { PlayerData data = new PlayerData(45, 0, WEEK, DAY, 0, java.util.Map.of()); - // 20 and 40 are reached; the last 5 points fall short of 60 assertEquals(2, data.claimable(List.of(20, 40, 60))); } @@ -412,8 +374,6 @@ void claimableNeverGoesNegativeWhenClaimedPointsExceedsPoints() { assertEquals(0, data.claimable(MILESTONES)); } - // Points past the daily max are lost: they must not reach the weekly bar - // today, nor leak into it once the day rolls over. @Test void pointsPastTheDailyMaxAreLost() { PlayerData data = data(); @@ -431,9 +391,6 @@ void pointsPastTheDailyMaxAreLost() { assertEquals(6, data.points()); } - // Product-owner example: dailyMax 10, barMax large enough to never bind. - // Earning "36 points" worth on day one and "25 points" worth on day two - // must land on weekly points 20 (10 + 10) and dailyPoints 10 (today only). @Test void productOwnerExampleTwoDaysOfThirtySixAndTwentyFive() { PlayerData data = data(); @@ -500,9 +457,6 @@ void weekRollResetsBothWeeklyAndDailyPoints() { assertEquals(0, data.points()); } - // Milestones must reflect what actually reached the weekly bar, not the - // raw earned amount before the daily clamp trims it: a raw 20 would cross - // two milestones (10 and 20), but a dailyMax of 5 trims it to 5. @Test void milestonesAreComputedFromTheDailyClampedAmount() { PlayerData data = data(); @@ -524,10 +478,6 @@ void dailyMaxLargerThanBarMaxStillClampsWeeklyAtBarMax() { assertEquals(barMax, result.pointsAwarded()); } - // Pinning documented behaviour: dailyPoints tracks only the amount that - // actually landed on the weekly total, not the (possibly larger) amount - // earned before the weekly bar clamped it. A weekly-clamped award only - // consumes as much daily budget as reached the bar. @Test void weeklyClampedAwardConsumesOnlyWhatReachedTheBar() { PlayerData data = data(); @@ -540,15 +490,10 @@ void weeklyClampedAwardConsumesOnlyWhatReachedTheBar() { assertEquals(5, data.dailyPoints()); } - // ==================================== - // bar.vote-share 50 on a daily-max of 10: everything but vote shares 5 - // ==================================== private RecordResult shared(PlayerData data, ActivityDef def, int amount, int nonVoteMax) { return data.record(amount, def, MAX, 10, nonVoteMax, MILESTONES); } - // A forced vote is not a vote point of the day: it must not open the - // non-vote share up past its 5 @Test void aForcedVoteDoesNotWidenTheNonVoteShare() { PlayerData data = data(); @@ -560,9 +505,6 @@ void aForcedVoteDoesNotWidenTheNonVoteShare() { assertEquals(0, data.votePoints()); } - // 5 vote + 5 non-vote, then a reroll the claimed 8 lets only 2 come back - // off: the 8 carried keep the day's half-and-half mix, so 4 non-vote are - // spent and 1 is left - not 0, as if every carried point were non-vote @Test void votePointsCarriedPastARerollStillCountAsVote() { PlayerData data = data(); @@ -608,8 +550,6 @@ void votingFirstLeavesTheNonVoteShareOpen() { assertEquals(10, data.dailyPoints()); } - // A row saved before vote-share existed: 8 non-vote points already in. - // Nothing is taken back, no more non-vote lands, vote still gets the rest. @Test void nonVotePointsAlreadyOverTheShareAreKept() { PlayerData data = new PlayerData(8, 8, WEEK, DAY, 0, Map.of("free", 8)); @@ -637,9 +577,6 @@ void zeroDailyBudgetAwardsNothing() { assertEquals(0, data.dailyPoints()); } - // dailyPoints already past dailyMax (e.g. read off disk after dailyMax was - // lowered) must not drive the remaining budget negative and give an award - // out of thin air. @Test void dailyPointsAlreadyOverTheLoweredMaxNeverGoesNegativeOrAwards() { PlayerData data = new PlayerData(0, 15, WEEK, DAY, 0, java.util.Map.of()); @@ -650,16 +587,11 @@ void dailyPointsAlreadyOverTheLoweredMaxNeverGoesNegativeOrAwards() { assertEquals(15, data.dailyPoints()); } - // ==================================== - // Which cap swallowed the points, so /activity add can name it instead of - // reporting a success nobody was credited for. - // ==================================== @Test void aCreditedPointReportsPlainSuccess() { assertEquals(Recorded.RECORDED, record(data(), VOTE, 1).outcome()); } - // Part-way to the next point is a success too - nothing capped it @Test void partialProgressTowardsTheNextPointIsNotACap() { assertEquals(Recorded.RECORDED, record(data(), INSTRUMENT, 1).outcome()); @@ -673,9 +605,6 @@ void anActivityAtItsOwnDailyCapSaysSo() { assertEquals(Recorded.ACTIVITY_CAP, record(data, VOTE, 1).outcome()); } - // A cap in points > 1 per award: worth() must compare against capPoints(), - // not the raw award count, or a cap of 1 would let a second every:1 award - // through when its points happen to be more than 1. @Test void anActivityCapWithMultiPointAwardsStopsAtItsCapPoints() { ActivityDef cappedAtOne = @@ -709,10 +638,6 @@ void aFullWeeklyBarSaysSo() { assertEquals(Recorded.WEEKLY_MAX, data.record(1, UNCAPPED, MAX, DAILY_MAX, MILESTONES).outcome()); } - // ==================================== - // PlayerData.due: the static helper claim() and claimable() both go - // through. Milestones [10, 20] throughout. - // ==================================== @Test void pointsExactlyOnAMilestoneOwesIt() { assertEquals(List.of(10), PlayerData.due(10, 0, MILESTONES)); @@ -723,8 +648,6 @@ void reachingTheSecondMilestoneOwesOnlyWhatWasNotYetClaimed() { assertEquals(List.of(20), PlayerData.due(20, 10, MILESTONES)); } - // Config order is not guaranteed ascending; due() must sort its own - // output regardless of the order milestones are given in. @Test void dueReturnsAscendingEvenWhenMilestonesAreUnsorted() { assertEquals(List.of(10, 20), PlayerData.due(30, 0, List.of(20, 10))); @@ -735,8 +658,6 @@ void everythingClaimedOwesNothingEvenWithPointsToSpare() { assertEquals(List.of(), PlayerData.due(50, 20, MILESTONES)); } - // A stale claimedPoints above the current points (e.g. milestones edited - // down after a claim) must owe nothing, not a negative count @Test void claimedAboveCurrentPointsOwesNothingAndNeverGoesNegative() { assertEquals(List.of(), PlayerData.due(5, 100, MILESTONES)); @@ -760,8 +681,6 @@ void oneRecordCallJumpingPastBothMilestonesCountsBothAsReached() { assertEquals(2, result.milestonesReached()); } - // The same two milestones must come due regardless of how the points were - // earned: 10 in one day then 10 the next, versus 5 a day for four days. @Test void theSameTwoMilestonesComeDueRegardlessOfPace() { PlayerData playerA = new PlayerData(WEEK, DAY); @@ -784,10 +703,6 @@ void theSameTwoMilestonesComeDueRegardlessOfPace() { assertEquals(List.of(10, 20), PlayerData.due(playerB.points(), playerB.claimedPoints(), MILESTONES)); } - // ==================================== - // The daily draw: up to TASKS_PER_DAY distinct loaded ids, wiped by both - // rollovers so the next day hands out a fresh, fully hidden set. - // ==================================== private static List ids(int count) { List ids = new java.util.ArrayList<>(); for (int i = 0; i < count; i++) { @@ -886,12 +801,6 @@ void theStoredConstructorKeepsOnlyRevealedFlagsThatAreTasks() { assertFalse(data.isRevealed("stranger")); } - // ==================================== - // An activity removed by /activity reload leaves a dead id in the draw. - // The next use drops it, discards its revealed flag and tops the draw back - // up out of what is still loaded - never below TASKS_PER_DAY while there - // are ids left to draw. - // ==================================== @Test void aRemovedActivityIsDroppedAndTheDrawToppedBackUp() { PlayerData data = new PlayerData(0, 0, WEEK, DAY, 0, java.util.Map.of(), @@ -903,7 +812,6 @@ void aRemovedActivityIsDroppedAndTheDrawToppedBackUp() { assertFalse(data.tasks().contains("gone"), "a removed activity stays in the draw"); assertEquals(data.tasks().size(), new HashSet<>(data.tasks()).size(), "the top-up repeats an id"); assertTrue(ids(30).containsAll(data.tasks())); - // The survivors keep their order; the refill lands at the end assertEquals(List.of("a0", "a1", "a2", "a3", "a4", "a5"), data.tasks().subList(0, 6)); } @@ -919,7 +827,6 @@ void aRefilledSlotIsUnrevealedAndTheDroppedFlagIsDiscarded() { assertFalse(data.isRevealed(data.tasks().get(6)), "the refilled slot is revealed"); } - // Nothing left to draw from: the draw just shrinks rather than looping @Test void aDrawCannotBeToppedUpPastWhatIsLoaded() { PlayerData data = new PlayerData(0, 0, WEEK, DAY, 0, java.util.Map.of(), @@ -929,10 +836,6 @@ void aDrawCannotBeToppedUpPastWhatIsLoaded() { assertEquals(List.of("a0", "a1"), data.tasks()); } - // ==================================== - // A guaranteed activity is in every draw, wherever the shuffle puts it, - // and a draw persisted without it gains it on the next use. - // ==================================== private static Set guaranteedSlots(List ids, List guaranteed, String id) { Set slots = new HashSet<>(); Random random = new Random(11); @@ -972,7 +875,6 @@ void fewerLoadedIdsThanSlotsStillIncludesTheGuaranteedOne() { assertEquals(new HashSet<>(ids(3)), new HashSet<>(drawn)); } - // Its plugin is missing, so config never loaded it - nothing special happens @Test void aGuaranteedIdThatIsNotLoadedIsSimplyNotDrawn() { List drawn = PlayerData.draw(ids(30), List.of("ghost"), new Random(5)); @@ -982,10 +884,6 @@ void aGuaranteedIdThatIsNotLoadedIsSimplyNotDrawn() { assertTrue(ids(30).containsAll(drawn)); } - // ==================================== - // More guaranteed ids than slots: the draw is TASKS_PER_DAY of them, - // randomly chosen and randomly ordered, and nothing else can get in. - // ==================================== @Test void moreGuaranteedIdsThanSlotsFillTheWholeDraw() { List guaranteed = ids(9); @@ -1012,7 +910,6 @@ void aPersistedDrawWithoutTheGuaranteedIdGainsItAndKeepsTheRest() { assertEquals(PlayerData.TASKS_PER_DAY, data.tasks().size()); assertTrue(data.tasks().contains("a9")); - // Exactly one of the old tasks made room, and it was an unrevealed one assertTrue(data.tasks().containsAll(List.of("a1", "a2")), "a revealed task was taken over"); assertEquals(Set.of("a1", "a2"), data.revealed(), "a revealed flag was lost"); assertEquals(6, data.tasks().stream().filter(id -> !id.equals("a9")).count()); @@ -1041,15 +938,6 @@ void aDrawThatAlreadyHoldsTheGuaranteedIdIsLeftAlone() { assertEquals(first, data.tasks()); } - // ==================================== - // The one bit of reroll arithmetic worth pinning: today's points come back - // off the weekly bar, but never below what has already been paid out, or - // the same milestone would become claimable twice. What the floor keeps on - // the bar was not refunded, so it carries forward as today's starting - // dailyPoints instead of handing the budget back for free: 12 - 5 = 7 is - // under the claimed 10, so only 2 of the 5 come off and the other 3 stay - // spent. - // ==================================== @Test void aRerollTakesTodayBackOffTheWeeklyBarButNeverBelowWhatWasClaimed() { PlayerData data = new PlayerData(12, 5, "w", "d", 10, Map.of("a1", 3), @@ -1076,8 +964,6 @@ void aDayRolloverGivesTheRerollBack() { assertEquals(0, data.rerolls()); } - // A week rollover resets the counter too, even when the day key inside it - // happens not to change - the counter is not keyed off the day string @Test void aWeekRolloverGivesTheRerollBackEvenWithTheSameDayKey() { PlayerData data = data(); @@ -1088,9 +974,6 @@ void aWeekRolloverGivesTheRerollBackEvenWithTheSameDayKey() { assertEquals(0, data.rerolls()); } - // Below the claimed floor, the ordinary case: dailyPoints fits entirely - // inside points - claimedPoints, so the subtraction lands exactly and the - // floor never engages @Test void anOrdinaryRerollDropsTheWeeklyTotalByExactlyTodaysPoints() { PlayerData data = new PlayerData(20, 6, "w", "d", 5, Map.of("a1", 3), @@ -1100,16 +983,9 @@ void anOrdinaryRerollDropsTheWeeklyTotalByExactlyTodaysPoints() { assertEquals(14, data.points()); assertEquals(5, data.claimedPoints()); - // Every one of today's points was refunded, so today starts over assertEquals(0, data.dailyPoints()); } - // ==================================== - // Claim-then-reroll: the whole of today sits on top of claimedPoints, so - // the floor blocks the entire subtraction. Nothing is refunded, so nothing - // of today's budget comes back either - the player keeps their 10 points - // and has 0 of bar.daily-max left to earn against. - // ==================================== @Test void aFullyFlooredRerollRefundsNothingAndCarriesTheWholeDayForward() { PlayerData data = new PlayerData(10, 10, "w", "d", 10, Map.of("a1", 3), @@ -1123,10 +999,6 @@ void aFullyFlooredRerollRefundsNothingAndCarriesTheWholeDayForward() { assertTrue(data.daily().isEmpty()); } - // ==================================== - // bar.max lowered under a claimedPoints that was earned against the old - // one: the reroll must not push the bar back over the new max. - // ==================================== @Test void aRerollNeverRaisesTheWeeklyTotalAboveTheBarMax() { PlayerData data = new PlayerData(8, 4, "w", "d", 20, Map.of(), @@ -1138,9 +1010,6 @@ void aRerollNeverRaisesTheWeeklyTotalAboveTheBarMax() { assertEquals(4, data.dailyPoints()); } - // Stored points above the bar max: the refund the reroll computes (12) - // is larger than dailyPoints (5). Without the Math.max(0, ...) floor on - // the refund itself, dailyPoints would go negative here. @Test void aRefundLargerThanDailyPointsFloorsAtZero() { PlayerData data = new PlayerData(20, 5, "w", "d", 0, Map.of(), @@ -1152,13 +1021,6 @@ void aRefundLargerThanDailyPointsFloorsAtZero() { assertEquals(0, data.dailyPoints()); } - // ==================================== - // Pins the exact regression from the bug report: bar.max was lowered and - // then raised back after claimedPoints (80) was already banked against - // the higher max, leaving stored points (60) below claimedPoints. The - // reroll then RAISES points (60 -> 80), so before - points is negative. - // dailyPoints must stay unchanged, not increase. - // ==================================== @Test void aRerollThatRaisesPointsDoesNotIncreaseDailyPoints() { PlayerData data = new PlayerData(60, 10, "w", "d", 80, Map.of(), @@ -1170,7 +1032,6 @@ void aRerollThatRaisesPointsDoesNotIncreaseDailyPoints() { assertEquals(10, data.dailyPoints()); } - // No points earned today means a reroll changes no points at all @Test void aRerollWithNoDailyPointsChangesNoPoints() { PlayerData data = new PlayerData(15, 0, "w", "d", 5, Map.of(), @@ -1183,7 +1044,6 @@ void aRerollWithNoDailyPointsChangesNoPoints() { assertEquals(0, data.dailyPoints()); } - // The counter keeps counting across multiple rerolls in the same day @Test void aSecondRerollTheSameDayIncrementsTheCounterAgain() { PlayerData data = data(); @@ -1202,10 +1062,6 @@ void aDrawnListCannotBeMutatedByItsCaller() { assertThrows(UnsupportedOperationException.class, () -> drawn.add("a99")); } - // ==================================== - // daily-reward's once-a-day flag - // ==================================== - @Test void theDailyRewardFlagIsClearedByTheNextDayAndTheNextWeek() { PlayerData data = new PlayerData("2026-W38", "2026-09-19"); @@ -1221,7 +1077,6 @@ void theDailyRewardFlagIsClearedByTheNextDayAndTheNextWeek() { assertFalse(data.dailyRewardClaimed()); } - // A fresh draw revealed again the same day must not pay a second time @Test void aRerollKeepsTheDailyRewardFlag() { PlayerData data = new PlayerData("2026-W38", "2026-09-19"); diff --git a/src/test/java/tfmc/justin/activity/models/RewardEntryTest.java b/src/test/java/tfmc/justin/activity/models/RewardEntryTest.java index 3ca5ddc..2cbc8cf 100644 --- a/src/test/java/tfmc/justin/activity/models/RewardEntryTest.java +++ b/src/test/java/tfmc/justin/activity/models/RewardEntryTest.java @@ -7,11 +7,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; -// ==================================== -// The weighted draw itself: RewardEntry.pick walks cumulative weights. -// ActivityManagerTest already pins the common/rare boundary case; this file -// covers the shape of the draw beyond that one pool. -// ==================================== class RewardEntryTest { private static final RewardEntry COMMON = new RewardEntry(3, "common", @@ -33,8 +28,6 @@ void theLastRollDrawsTheLastEntry() { @Test void everyBoundaryRollAcrossThreeAndOneWeights() { - // weights 3 then 1: rolls 0,1,2 land on the first entry, roll 3 is the - // first roll to cross into the second assertEquals(COMMON, RewardEntry.pick(POOL, 0)); assertEquals(COMMON, RewardEntry.pick(POOL, 1)); assertEquals(COMMON, RewardEntry.pick(POOL, 2)); @@ -49,18 +42,12 @@ void aSingleEntryPoolAlwaysDrawsItRegardlessOfRoll() { assertEquals(COMMON, RewardEntry.pick(single, 2)); } - // Pinning documented behaviour: a roll below the range still walks off - // the first cumulative threshold and lands on the first entry, since - // "roll < cursor" is true for every negative roll too. @Test void aNegativeRollLandsOnTheFirstEntry() { assertEquals(COMMON, RewardEntry.pick(POOL, -1)); assertEquals(COMMON, RewardEntry.pick(POOL, Integer.MIN_VALUE)); } - // Pinning documented behaviour: a roll at or beyond totalWeight (a caller - // that rolled against a stale total) takes the last entry rather than - // paying nothing. @Test void aRollAtOrBeyondTheTotalWeightLandsOnTheLastEntry() { int total = RewardEntry.totalWeight(POOL); diff --git a/src/test/java/tfmc/justin/activity/store/PlayerStoreTest.java b/src/test/java/tfmc/justin/activity/store/PlayerStoreTest.java index 30b1478..7d8704b 100644 --- a/src/test/java/tfmc/justin/activity/store/PlayerStoreTest.java +++ b/src/test/java/tfmc/justin/activity/store/PlayerStoreTest.java @@ -19,23 +19,10 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; -// ==================================== -// PlayerStore's parsing/snapshot rules are pure logic over a -// ConfigurationSection / PlayerData, but PlayerStore itself needs a live -// JavaPlugin + ActivityConfiguration to construct, which this suite cannot -// build. Both rules are therefore exercised through the package-private -// statics the instance methods delegate to: -// -// - readEntry(ConfigurationSection root, String key, int barMax, int dailyMax, Predicate), returning -// null for a missing section or a key that is not a UUID -// - snapshot(Map) -// ==================================== class PlayerStoreTest { private static final int BAR_MAX = 20; private static final int DAILY_MAX = 10; - // Stands in for "this id is a loaded activity"; the unknown-task test - // below hands in a narrower one private static final Predicate KNOWN = id -> true; @Test @@ -140,12 +127,6 @@ void votePointsRoundTripThroughSnapshotAndReadEntry() { assertEquals(3, PlayerStore.readEntry(players, id.toString(), BAR_MAX, DAILY_MAX, KNOWN).votePoints()); } - // ==================================== - // A row saved before vote-points existed: today's vote count's worth - // (daily-cap 5 of the 7 votes) is taken as what voting put in, never more - // than the day's points. With no vote activity loaded there is nothing to - // go on, and 0 it is. - // ==================================== @Test void aRowWithoutVotePointsFallsBackToWhatTodaysVotesAreWorth() { ConfigurationSection root = new YamlConfiguration().createSection("players"); @@ -163,25 +144,13 @@ void aRowWithoutVotePointsFallsBackToWhatTodaysVotesAreWorth() { assertEquals(2, PlayerStore.readEntry(root, id.toString(), BAR_MAX, DAILY_MAX, KNOWN, vote).votePoints()); } - // ==================================== - // /activity add --force awards past both daily limits, so what it leaves - // behind has to survive a save and a load unchanged: parse() clamps points - // to bar.max and daily-points to bar.daily-max, and a forced add that - // pushed either past its clamp would be silently cut back on the next - // restart - memory and disk disagreeing about a live player. - // ==================================== @Test void aForcedAwardAgreesBetweenMemoryAndDisk() throws InvalidConfigurationException { UUID id = UUID.randomUUID(); PlayerData inMemory = new PlayerData(0, 0, "2026-09-07", "2026-09-09", 0, Map.of()); - // every: 1, points: 1, daily-cap: 5 - the shipped vote activity, with - // BAR_MAX (20) well under what 50 actions are worth uncapped ActivityDef vote = new ActivityDef("vote", "Vote", Material.PAPER, null, 1, 1, 5); inMemory.recordForced(50, vote, BAR_MAX, List.of()); - // Through the file's own text, not just the in-memory snapshot: the - // daily counts go in as a plain Map and only become a section once - // YAML has been written and read back YamlConfiguration onDisk = new YamlConfiguration(); onDisk.loadFromString(PlayerStore.snapshot(Map.of(id, inMemory)).saveToString()); PlayerData fromDisk = PlayerStore.readEntry(onDisk.getConfigurationSection("players"), @@ -191,7 +160,6 @@ void aForcedAwardAgreesBetweenMemoryAndDisk() throws InvalidConfigurationExcepti assertEquals(inMemory.dailyPoints(), fromDisk.dailyPoints()); assertEquals(inMemory.claimedPoints(), fromDisk.claimedPoints()); assertEquals(inMemory.count("vote"), fromDisk.count("vote")); - // and neither clamp had anything to cut assertEquals(BAR_MAX, fromDisk.points()); assertEquals(0, fromDisk.dailyPoints()); assertEquals(50, fromDisk.count("vote")); @@ -262,11 +230,6 @@ void nonZeroEntryIsWrittenWithAllExpectedKeys() { assertTrue(yaml.isSet(path + ".daily")); } - // ==================================== - // Today's draw and which of its slots have been revealed survive a - // restart, or a player would be handed a fresh set of hidden tasks every - // time the server came back up. - // ==================================== @Test void theDrawAndRevealedFlagsRoundTripThroughSnapshotAndReadEntry() { UUID id = UUID.randomUUID(); @@ -283,8 +246,6 @@ void theDrawAndRevealedFlagsRoundTripThroughSnapshotAndReadEntry() { assertFalse(parsed.isRevealed("mine")); } - // A player whose only state is today's draw is still worth writing, or - // the draw would be lost on the first save @Test void aPlayerWithOnlyADrawIsStillWritten() { UUID id = UUID.randomUUID(); @@ -297,7 +258,6 @@ void aPlayerWithOnlyADrawIsStillWritten() { assertEquals(List.of("vote"), yaml.getStringList("players." + id + ".tasks")); } - // An activity dropped from config.yml is not a task any more @Test void aStoredTaskWhoseActivityIsGoneIsIgnored() { ConfigurationSection root = new YamlConfiguration().createSection("players"); @@ -315,10 +275,6 @@ void aStoredTaskWhoseActivityIsGoneIsIgnored() { assertTrue(data.isRevealed("quest")); } - // ==================================== - // A draw is all a row needs to be worth keeping: the player has been - // handed today's tasks and a restart must not re-roll them. - // ==================================== @Test void aDrawOnlyEntryIsStillWritten() { UUID id = UUID.randomUUID(); @@ -330,10 +286,6 @@ void aDrawOnlyEntryIsStillWritten() { assertEquals(List.of("vote", "quest"), yaml.getStringList("players." + id + ".tasks")); } - // ==================================== - // revealed() is a hash set, so its iteration order is not the order the - // ids went in - written as-is it would churn the file between saves. - // ==================================== @Test void revealedIsWrittenSortedSoTheFileDoesNotChurn() { UUID id = UUID.randomUUID(); @@ -349,12 +301,6 @@ void revealedIsWrittenSortedSoTheFileDoesNotChurn() { assertEquals(first, second); } - // ==================================== - // rerolls: same round-trip and clamping rules as the other counters on - // this entry - a missing key defaults to zero, a negative or garbled - // stored value is clamped rather than trusted, and the count survives a - // save/load cycle. - // ==================================== @Test void rerollsRoundTripsThroughSnapshotAndReadEntry() { UUID id = UUID.randomUUID(); @@ -392,8 +338,6 @@ void aNegativeStoredRerollsIsClampedToZero() { assertEquals(0, data.rerolls()); } - // A non-numeric value is exactly what getInt() already falls back to 0 - // for, the same as every other counter parsed off this entry @Test void aNonNumericStoredRerollsDoesNotCrashAndLoadsAsZero() { ConfigurationSection root = new YamlConfiguration().createSection("players"); @@ -407,8 +351,6 @@ void aNonNumericStoredRerollsDoesNotCrashAndLoadsAsZero() { assertEquals(0, data.rerolls()); } - // A player whose only non-default state is a reroll count is still worth - // writing, or a restart would hand their budget straight back to them @Test void aPlayerWithOnlyARerollCountIsStillWritten() { UUID id = UUID.randomUUID(); @@ -420,11 +362,6 @@ void aPlayerWithOnlyARerollCountIsStillWritten() { assertEquals(1, yaml.getInt("players." + id + ".rerolls")); } - // ==================================== - // Disk and memory must end up with the same draw when an activity is - // removed: the read path drops the dead id, the in-memory path drops it - // too, and both are topped back up to TASKS_PER_DAY on next use. - // ==================================== @Test void aDrawWithARemovedActivityAgreesBetweenDiskAndMemory() { List stored = List.of("a0", "gone", "a1", "a2", "a3", "a4", "a5"); @@ -447,7 +384,6 @@ void aDrawWithARemovedActivityAgreesBetweenDiskAndMemory() { assertFalse(fromDisk.tasks().contains("gone")); } - // A row saved before daily-reward existed has no key for it @Test void aRowWithoutTheDailyRewardKeyLoadsAsNotClaimed() { ConfigurationSection root = new YamlConfiguration().createSection("players"); diff --git a/src/test/java/tfmc/justin/activity/utils/BarTest.java b/src/test/java/tfmc/justin/activity/utils/BarTest.java index 979bd5e..ef3ff98 100644 --- a/src/test/java/tfmc/justin/activity/utils/BarTest.java +++ b/src/test/java/tfmc/justin/activity/utils/BarTest.java @@ -14,14 +14,12 @@ private static String render(int points) { return Bar.render(points, 20, 10); } - // Everything between the gray brackets, colour codes stripped private static String segments(String bar) { return bar.replaceAll("#[0-9a-f]{6}", "").replace("[", "").replace("]", ""); } @Test void emptyBarIsAllDimSegments() { - // 0xaa0000 at a third of the brightness assertEquals("#aaaaaa[#380000" + "|".repeat(10) + "#aaaaaa]", render(0)); } @@ -37,7 +35,6 @@ void halfBarInterpolatesAndDimsTheEmptyRun() { @Test void halfASegmentRoundsUp() { - // 1/20 of 10 segments is exactly 0.5, which Math.round takes up to 1 assertEquals("#aaaaaa[#a20d00|#360400" + "|".repeat(9) + "#aaaaaa]", render(1)); } @@ -86,16 +83,8 @@ void colorizedBarHasNoLeftoverHexMarkersButHasHexColorCodes() { assertTrue(colorized.contains("§x"), "expected bungee hex color sequences: " + colorized); } - // ==================================== - // The milestone markers: one segment each, one before - // round(milestone / max * length) - the segment that fills the instant - // the milestone is reached - in their own colour: gold while ahead, aqua - // once filled, independent of the run's gradient. - // ==================================== @Test void milestonesMarkTheirSegment() { - // max 20, length 10: 10 points marks segment index 4, 20 points would - // land on index 10 - off the end, so it marks the last segment instead String bar = Bar.render(0, 20, 10, List.of(10, 20)); assertEquals("||||┃||||┃", segments(bar)); @@ -103,28 +92,24 @@ void milestonesMarkTheirSegment() { @Test void aReachedMarkerIsAquaAndAnAheadMarkerIsGold() { - // value == milestone (10): that marker has filled; 20 is still ahead assertEquals("#aaaaaa[#558000||||#55ffff┃#1c2a00||||#ffd700┃#aaaaaa]", Bar.render(10, 20, 10, List.of(10, 20))); } @Test void aMarkerOneSegmentShortOfFilledIsStillGold() { - // 8/20 fills 4 segments; the 10 marker is index 4, the next to fill assertEquals("#aaaaaa[#666600||||#ffd700┃#222200|||||#aaaaaa]", Bar.render(8, 20, 10, List.of(10))); } @Test void aMarkerTheRoundedFillCoversIsStillGoldUntilReached() { - // 9/20 rounds to 5 filled segments, covering the 10 marker at index 4 assertEquals("#aaaaaa[#5e7300||||#ffd700┃#1f2600|||||#aaaaaa]", Bar.render(9, 20, 10, List.of(10))); } @Test void aSharedSegmentIsReachedOnlyOnceEveryMilestoneOnItIs() { - // length 2, max 20: 9 and 10 both land on index 0 assertTrue(Bar.render(9, 20, 2, List.of(9, 10)).contains(Bar.MARKER_AHEAD + "┃")); assertTrue(Bar.render(10, 20, 2, List.of(9, 10)).contains(Bar.MARKER_REACHED + "┃")); } @@ -149,16 +134,12 @@ void theSegmentCountIsUnchangedByMarkers() { assertEquals(10, segments(Bar.render(7, 20, 10, List.of(5, 10, 15, 20))).length()); } - // max 50, length 40: 10/50*40 = 8.0 and 20/50*40 = 16.0, one segment - // earlier than that (the segment that fills when the milestone is - // reached) lands on indices 7 and 15 @Test void milestonesMarkTheirComputedSegmentIndex() { String bar = segments(Bar.render(0, 50, 40, List.of(10, 20))); assertEquals('┃', bar.charAt(7)); assertEquals('┃', bar.charAt(15)); - // every other segment stays the plain pipe assertEquals(38, bar.chars().filter(c -> c == '|').count()); } @@ -169,9 +150,6 @@ void aMilestoneExactlyAtMaxMarksTheLastSegment() { assertEquals("|".repeat(39) + "┃", bar); } - // Pinning documented behaviour: a milestone above max (e.g. bar.max was - // lowered after it was configured) is clamped to the last segment rather - // than falling off the end of the array. @Test void aMilestoneAboveMaxClampsToTheLastSegment() { String bar = segments(Bar.render(0, 50, 40, List.of(1000))); @@ -181,8 +159,6 @@ void aMilestoneAboveMaxClampsToTheLastSegment() { @Test void theRunColourResumesAfterAMarker() { - // Bar is entirely full; 12/20*10 = 6.0, one segment earlier, so the - // marker lands at index 5 and the green run picks up again after it assertEquals("#aaaaaa[#00ff00" + "|".repeat(5) + "#55ffff┃#00ff00" + "|".repeat(4) + "#aaaaaa]", Bar.render(20, 20, 10, List.of(12))); } diff --git a/src/test/java/tfmc/justin/activity/utils/ItemPathTest.java b/src/test/java/tfmc/justin/activity/utils/ItemPathTest.java index 49d89ed..c1f8647 100644 --- a/src/test/java/tfmc/justin/activity/utils/ItemPathTest.java +++ b/src/test/java/tfmc/justin/activity/utils/ItemPathTest.java @@ -8,10 +8,6 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; -// ==================================== -// How the four forms an item-valued config key accepts are told apart. -// Material.matchMaterial is a name lookup, so all of this runs headless. -// ==================================== class ItemPathTest { @Test @@ -33,7 +29,6 @@ void aVanillaPathIsTheSameMaterial() { void anItemPathIsKeptWhole() { assertTrue(ItemPath.isPluginPath("m.material.steel")); assertEquals("m.material.steel", ItemPath.pluginPath("m.material.steel")); - // never a Material, so it can never be mistaken for a typo'd one assertNull(ItemPath.material("m.material.steel")); } @@ -91,12 +86,6 @@ void anUnrecognizedPrefixIsUnsupportedNotUnknown() { assertFalse(ItemPath.isUnsupportedPath("ia.tfmc:saucepan")); } - // ==================================== - // ia. - the id /iagive takes. It is a path, never a - // material, and never "unsupported": the whole point of the form is that - // the admin gets an ItemsAdder lookup rather than a warning. - // ==================================== - @Test void anItemsAdderPathIsAPathNotAMaterialAndNotUnsupported() { assertTrue(ItemPath.isItemsAdderPath("ia.tfmc:saucepan")); @@ -106,8 +95,6 @@ void anItemsAdderPathIsAPathNotAMaterialAndNotUnsupported() { assertNull(ItemPath.pluginPath("ia.tfmc:saucepan")); } - // The colon form goes to ItemsAdder untouched; the dotted form an admin - // writes by analogy with m.. means the same item @Test void bothSeparatorsNormalizeToTheColonForm() { assertEquals("tfmc:saucepan", ItemPath.itemsAdderId("ia.tfmc:saucepan")); @@ -116,9 +103,6 @@ void bothSeparatorsNormalizeToTheColonForm() { assertEquals("tfmc:saucepan", ItemPath.itemsAdderId(" Ia.tfmc:saucepan ")); } - // Every one of these is still an ia. path - so it never falls through to - // matchMaterial - but there is no id to hand over, and asking must not - // throw @Test void aMalformedItemsAdderPathIsRejectedAsAPathNotAsAMaterial() { for (String broken : new String[]{"ia.", "ia.:", "ia.ns:", "ia.:id", "ia.a:b:c", @@ -130,8 +114,6 @@ void aMalformedItemsAdderPathIsRejectedAsAPathNotAsAMaterial() { } } - // 'ia' alone has no prefix at all: it is just an unknown material name, - // and asking for an id must answer null rather than index out of bounds @Test void theBarePrefixIsNotAnItemsAdderPath() { assertFalse(ItemPath.isItemsAdderPath("ia")); diff --git a/src/test/java/tfmc/justin/activity/utils/WeeksTest.java b/src/test/java/tfmc/justin/activity/utils/WeeksTest.java index aeef83d..494e287 100644 --- a/src/test/java/tfmc/justin/activity/utils/WeeksTest.java +++ b/src/test/java/tfmc/justin/activity/utils/WeeksTest.java @@ -12,7 +12,6 @@ class WeeksTest { @Test void midWeekResolvesToThatMonday() { - // Wednesday 2026-09-09 assertEquals("2026-09-07", Weeks.weekKey(LocalDateTime.of(2026, 9, 9, 14, 30), DayOfWeek.MONDAY, 0)); } @@ -28,25 +27,21 @@ void beforeTheResetHourStillCountsAsThePreviousWeek() { @Test void sundayResetFromSaturdayResolvesToThePreviousSunday() { - // Saturday 2026-09-12 assertEquals("2026-09-06", Weeks.weekKey(LocalDateTime.of(2026, 9, 12, 10, 0), DayOfWeek.SUNDAY, 0)); } @Test void weekCanStartInThePreviousYear() { - // Friday 2027-01-01 assertEquals("2026-12-28", Weeks.weekKey(LocalDateTime.of(2027, 1, 1, 12, 0), DayOfWeek.MONDAY, 0)); } @Test void lateResetHourAtExactlyTheBoundaryStartsTheNewWeek() { - // Monday 2026-09-07 23:00, reset hour 23 assertEquals("2026-09-07", Weeks.weekKey(LocalDateTime.of(2026, 9, 7, 23, 0), DayOfWeek.MONDAY, 23)); } @Test void oneMinuteBeforeALateResetHourIsStillThePreviousWeek() { - // Monday 2026-09-07 22:59, reset hour 23 assertEquals("2026-08-31", Weeks.weekKey(LocalDateTime.of(2026, 9, 7, 22, 59), DayOfWeek.MONDAY, 23)); } @@ -60,9 +55,6 @@ void dayKeyIsTheIsoDate() { assertEquals("2026-09-09", Weeks.dayKey(LocalDateTime.of(2026, 9, 9, 23, 59).toLocalDate())); } - // One clock, two keys: on both sides of the reset boundary the day key - // must belong to the week key derived from the same moment, i.e. - // weekKey(dayKey's date) still resolves to that same week key. @Test void dayAndWeekKeysStayConsistentAcrossTheMidnightBoundary() { LocalDateTime beforeMidnight = LocalDateTime.of(2026, 9, 6, 23, 59, 59); @@ -78,8 +70,6 @@ void dayAndWeekKeysStayConsistentAcrossTheMidnightBoundary() { assertEquals("2026-09-07", weekAfter); assertEquals("2026-09-07", dayAfter); - // The day key, re-fed as "now" at the start of that day, must resolve - // to the same week key that was derived alongside it. assertEquals(weekBefore, Weeks.weekKey(LocalDate.parse(dayBefore).atStartOfDay(), DayOfWeek.MONDAY, 0)); assertEquals(weekAfter, Weeks.weekKey(LocalDate.parse(dayAfter).atStartOfDay(), DayOfWeek.MONDAY, 0)); }