From fcfc08217000386a0b367e1b5411b677b87a454c Mon Sep 17 00:00:00 2001 From: XxFran10xX Date: Tue, 6 Oct 2026 18:08:02 +0200 Subject: [PATCH] feat: lock activity rewards for the whole week A reload or restart used to change what the current week's milestones pay, so switching act pools mid-week paid unclaimed milestones from the new act. The first load of each week now copies rewards, bar.milestones and daily-reward.groups to reward-lock.yml, and the week pays from that copy. Reloads only stage config.yml's rewards; they replace the lock at the next weekly reset. /activity rewards shows the locked week and whether changes are waiting, and /activity rewards apply reloads and uses them right away. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 1 + .../activitytf/commands/ActivityCommand.java | 46 ++- .../config/ActivityConfiguration.java | 350 ++++++++++++++---- .../activitytf/store/PlayerStore.java | 2 + src/main/resources/config.yml | 12 +- src/main/resources/messages.yml | 7 +- src/main/resources/plugin.yml | 2 +- .../commands/ActivityAdminCommandTest.java | 67 +++- .../ActivityConfigurationRewardLockTest.java | 298 +++++++++++++++ 9 files changed, 705 insertions(+), 80 deletions(-) create mode 100644 src/test/java/net/tfminecraft/activitytf/config/ActivityConfigurationRewardLockTest.java diff --git a/README.md b/README.md index 7d86b2d..3249fac 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Tasks only start earning activity credit once revealed, giving players a reason - **Personal daily task menus** — a fresh selection of activities, with progress displays and descriptions for revealed tasks. - **Weekly reward milestones** — activity points unlock claimable rewards from weighted reward pools. +- **Week-locked rewards** — each week pays from the reward pools it started with; reward edits apply at the next weekly reset, or right away with `/activity rewards apply`. - **Daily reveal rewards** — players can claim a separate reward after revealing all of their daily tasks. - **Activities across the server** — supported integrations track actions such as voting, cooking, archaeology, instrument playing, market sales, and vehicle building. - **Varied progression limits** — daily caps and a voting share of the daily allowance shape how points are earned. diff --git a/src/main/java/net/tfminecraft/activitytf/commands/ActivityCommand.java b/src/main/java/net/tfminecraft/activitytf/commands/ActivityCommand.java index 9865e17..dd8da46 100644 --- a/src/main/java/net/tfminecraft/activitytf/commands/ActivityCommand.java +++ b/src/main/java/net/tfminecraft/activitytf/commands/ActivityCommand.java @@ -28,7 +28,9 @@ public class ActivityCommand implements CommandExecutor, TabCompleter { private static final List SUBCOMMANDS = - Arrays.asList("reload", "check", "reset", "givereroll", "add", "addpoints"); + Arrays.asList("reload", "rewards", "check", "reset", "givereroll", "add", "addpoints"); + + private static final String APPLY = "apply"; private static final List READ_ONLY_SUBCOMMANDS = List.of("check"); @@ -80,8 +82,12 @@ public boolean onCommand(CommandSender sender, Command command, String label, St case "reload": manager.reload(); sender.sendMessage(messages().get("admin.reloaded")); + rewardStatus(sender); audit(sender, "action=reload result=done"); return true; + case "rewards": + handleRewards(sender, args); + return true; case "check": handleCheck(sender, args); return true; @@ -103,6 +109,40 @@ public boolean onCommand(CommandSender sender, Command command, String label, St } } + private void handleRewards(CommandSender sender, String[] args) { + if (args.length == 1) { + rewardStatus(sender); + return; + } + if (args.length != 2 || !args[1].equalsIgnoreCase(APPLY)) { + usage(sender); + audit(sender, "action=rewards-apply result=usage"); + return; + } + + manager.reload(); + ActivityConfiguration config = manager.getConfiguration(); + if (!config.applyRewardsNow()) { + sender.sendMessage(messages().get("admin.rewards-apply-failed")); + audit(sender, "action=rewards-apply week=" + config.rewardWeek() + " result=save-failed"); + return; + } + sender.sendMessage(messages().get("admin.rewards-applied", "%week%", config.rewardWeek())); + audit(sender, "action=rewards-apply week=" + config.rewardWeek() + " result=done"); + } + + private void rewardStatus(CommandSender sender) { + ActivityConfiguration config = manager.getConfiguration(); + String week = config.rewardWeek(); + if (week == null) { + return; + } + sender.sendMessage(messages().get("admin.rewards-locked", "%week%", week)); + sender.sendMessage(config.rewardsPending() + ? messages().get("admin.rewards-pending", "%reset%", config.nextReset(week)) + : messages().get("admin.rewards-in-sync")); + } + private void usage(CommandSender sender) { sender.sendMessage(messages().get("admin.usage")); sender.sendMessage(messages().get("admin.usage-addpoints")); @@ -428,6 +468,10 @@ public List onTabComplete(CommandSender sender, Command command, String return filter(onlineNames(), args[1]); } + if (args.length == 2 && sub.equals("rewards")) { + return filter(List.of(APPLY), args[1]); + } + if (args.length == 5 && sub.equals("add")) { return filter(List.of(FORCE), args[4]); } diff --git a/src/main/java/net/tfminecraft/activitytf/config/ActivityConfiguration.java b/src/main/java/net/tfminecraft/activitytf/config/ActivityConfiguration.java index 89da819..121a49e 100644 --- a/src/main/java/net/tfminecraft/activitytf/config/ActivityConfiguration.java +++ b/src/main/java/net/tfminecraft/activitytf/config/ActivityConfiguration.java @@ -4,6 +4,7 @@ import org.bukkit.Material; import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.file.FileConfiguration; +import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.inventory.ItemStack; import org.bukkit.plugin.java.JavaPlugin; import net.tfminecraft.activitytf.hooks.TLibsItems; @@ -16,7 +17,10 @@ import net.tfminecraft.activitytf.utils.Utils; import net.tfminecraft.activitytf.utils.Weeks; +import java.io.File; +import java.io.IOException; import java.time.DayOfWeek; +import java.time.LocalDate; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Collections; @@ -30,6 +34,8 @@ import java.util.Set; import java.util.TreeSet; import java.util.function.Predicate; +import java.util.logging.Level; +import java.util.logging.Logger; public class ActivityConfiguration { @@ -72,6 +78,27 @@ public class ActivityConfiguration { private volatile Map dailyRewards = Map.of(); + // Rewards are locked per week: a reload only stages config.yml's rewards, and they replace the + // locked ones at the next weekly reset (or on /activity rewards apply). + public static final String REWARD_LOCK_FILE = "reward-lock.yml"; + + private static final Logger QUIET = quietLogger(); + + public record RewardTable(Map> pools, int multiplier, Set multiplierPools, + List milestones, Map drops, + Map daily) { + } + + private volatile String rewardWeek; + + private volatile boolean rewardsPending; + + private RewardTable stagedRewards; + + private YamlConfiguration stagedSnapshot; + + private boolean quiet; + public static final String DEFAULT_POOL = "pool"; public static final RewardEntry DAILY_POOL = new RewardEntry(0, DEFAULT_POOL, List.of(), List.of()); @@ -141,31 +168,25 @@ public void load() { itemsAdderPathConfigured = false; loadActivities(config.getConfigurationSection("activities")); - rewardPools = loadRewardPools(config); - rewardMultiplier = rewardMultiplier(config); - multiplierPools = loadMultiplierPools(config); - barMax = Math.max(1, config.getInt("bar.max", 50)); dailyMax = Math.max(1, config.getInt("bar.daily-max", 10)); voteShare = parseVoteShare(config); warnIfVoteShareMisfits(); - milestones = loadMilestones(config.getIntegerList("bar.milestones")); - 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."); + + synchronized (this) { + stageRewards(config); + lockRewards(lockFile(), currentKeys().week(), false); } - dailyRewards = loadDailyRewards(config); String itemPathProblem = itemPathWarning(pluginPathConfigured || !craftPaths.isEmpty(), itemPathsUsable, missingItemPathPlugins); if (itemPathProblem != null) { - plugin.getLogger().warning(itemPathProblem); + log().warning(itemPathProblem); } String itemsAdderProblem = itemsAdderWarning(itemsAdderPathConfigured, itemsAdderUsable); if (itemsAdderProblem != null) { - plugin.getLogger().warning(itemsAdderProblem); + log().warning(itemsAdderProblem); } guiTitle = config.getString("gui.title", "&8Weekly Activity"); @@ -190,13 +211,194 @@ public void load() { afkMinutes = Math.max(0, config.getInt("playtime.afk-minutes", 5)); if (config.contains("playtime.afk-minutes") && !activities.containsKey("playtime")) { - plugin.getLogger().warning("playtime.afk-minutes is set but there is no 'playtime' activity" + log().warning("playtime.afk-minutes is set but there is no 'playtime' activity" + " - no minute will ever be credited."); } warnIfBarUnreachable(); } + private Logger log() { + return quiet ? QUIET : plugin.getLogger(); + } + + private static Logger quietLogger() { + Logger logger = Logger.getAnonymousLogger(); + logger.setUseParentHandlers(false); + logger.setLevel(Level.OFF); + return logger; + } + + private File lockFile() { + return new File(plugin.getDataFolder(), REWARD_LOCK_FILE); + } + + // Parses config.yml's rewards, with warnings, and keeps them as the staged table. + void stageRewards(ConfigurationSection config) { + stagedSnapshot = rewardSnapshot(config); + stagedRewards = parseRewards(stagedSnapshot); + } + + /** + * Picks the rewards this week pays. A lock for the same week wins over the staged table unless + * {@code applyNow}; any other case locks the staged table for {@code week}. + */ + synchronized boolean lockRewards(File file, String week, boolean applyNow) { + String staged = stagedSnapshot.saveToString(); + YamlConfiguration lock = applyNow ? null : readLock(file, week); + if (lock != null) { + rewardWeek = week; + if (lock.saveToString().equals(staged)) { + useRewards(stagedRewards); + rewardsPending = false; + return true; + } + quiet = true; + try { + parseRewards(lock); + } finally { + quiet = false; + } + rewardsPending = true; + log().info("The rewards in config.yml differ from the ones locked for the week of " + week + + " - this week keeps the locked rewards, and config.yml's apply from the reset on " + + nextReset(week) + ". Run /activity rewards apply to use them now."); + return true; + } + + boolean saved = writeLock(file, week, stagedSnapshot); + if (!saved && applyNow) { + return false; + } + boolean newWeek = rewardWeek != null && !rewardWeek.equals(week); + useRewards(stagedRewards); + rewardWeek = week; + rewardsPending = false; + if (newWeek) { + log().info("New activity week " + week + " - config.yml's rewards are now locked for it."); + } + return saved; + } + + /** Called with each player-week roll; locks the staged rewards once the week has changed. */ + public void rollRewardWeek(String week) { + String locked = rewardWeek; + if (locked != null && !locked.equals(week)) { + synchronized (this) { + if (!week.equals(rewardWeek)) { + lockRewards(lockFile(), week, false); + } + } + } + } + + /** Makes config.yml's (staged) rewards apply to the current week straight away. */ + public boolean applyRewardsNow() { + return lockRewards(lockFile(), currentKeys().week(), true); + } + + private YamlConfiguration readLock(File file, String week) { + if (!file.isFile()) { + return null; + } + YamlConfiguration lock = YamlConfiguration.loadConfiguration(file); + String lockedWeek = lock.getString("week"); + ConfigurationSection locked = lock.getConfigurationSection("rewards-config"); + if (lockedWeek == null || locked == null) { + log().warning(REWARD_LOCK_FILE + " has no week or rewards-config - locking config.yml's rewards" + + " for this week instead."); + return null; + } + return lockedWeek.equals(week) ? rewardSnapshot(locked) : null; + } + + private boolean writeLock(File file, String week, YamlConfiguration snapshot) { + YamlConfiguration lock = new YamlConfiguration(); + lock.options().setHeader(List.of( + "The activity rewards locked for one week. Written by the plugin - do not edit.", + "Edit config.yml instead: its rewards replace these at the next weekly reset,", + "or right away with /activity rewards apply.")); + lock.set("week", week); + lock.createSection("rewards-config", plainSection(snapshot)); + try { + lock.save(file); + return true; + } catch (IOException e) { + log().severe("Could not save " + REWARD_LOCK_FILE + " (" + e.getMessage() + ") - config.yml's" + + " rewards are used, but a restart this week may not keep them."); + return false; + } + } + + // Everything that decides what a claim pays. bar.milestones is copied as read (jar default included), + // the rest only where config.yml sets it, so a lock never picks up the jar's example rewards. + static YamlConfiguration rewardSnapshot(ConfigurationSection config) { + YamlConfiguration snapshot = new YamlConfiguration(); + snapshot.set("bar.milestones", config.getIntegerList("bar.milestones")); + for (String path : List.of(REWARDS_PATH, DAILY_REWARD_GROUPS_PATH)) { + if (!set(config, path)) { + continue; + } + Object value = config.get(path); + if (value instanceof ConfigurationSection section) { + snapshot.createSection(path, plainSection(section)); + } else { + snapshot.set(path, value); + } + } + return snapshot; + } + + private static Map plainSection(ConfigurationSection section) { + Map values = new LinkedHashMap<>(); + for (String key : keys(section)) { + Object value = section.get(key); + values.put(key, value instanceof ConfigurationSection child ? plainSection(child) : value); + } + return values; + } + + private RewardTable parseRewards(ConfigurationSection source) { + rewardPools = loadRewardPools(source); + rewardMultiplier = rewardMultiplier(source); + multiplierPools = loadMultiplierPools(source); + milestones = loadMilestones(source.getIntegerList("bar.milestones")); + milestoneDrops = loadMilestoneDrops(source); + if (poolNeededButEmpty(rewardPool(), milestoneDrops, milestones)) { + log().warning("rewards.pool is empty or every entry in it was dropped - a milestone" + + " can be reached but nothing can ever be claimed."); + } + dailyRewards = loadDailyRewards(source); + return activeRewards(); + } + + private void useRewards(RewardTable table) { + rewardPools = table.pools(); + rewardMultiplier = table.multiplier(); + multiplierPools = table.multiplierPools(); + milestones = table.milestones(); + milestoneDrops = table.drops(); + dailyRewards = table.daily(); + } + + public RewardTable activeRewards() { + return new RewardTable(rewardPools, rewardMultiplier, multiplierPools, milestones, milestoneDrops, + dailyRewards); + } + + public String rewardWeek() { + return rewardWeek; + } + + /** True when config.yml's rewards differ from the locked ones and wait for the next reset. */ + public boolean rewardsPending() { + return rewardsPending; + } + + public String nextReset(String week) { + return LocalDate.parse(week).plusWeeks(1) + String.format(Locale.ROOT, " %02d:00", resetHour); + } + static final String REROLLS_PER_DAY_PATH = "reroll.per-day"; static final String REROLL_MAX_POINTS_PATH = "reroll.max-points"; @@ -223,14 +425,14 @@ private int clamped(ConfigurationSection config, String path, int fallback, int return fallback; } if (!(raw instanceof Number number)) { - plugin.getLogger().warning(path + " is not a number ('" + log().warning(path + " is not a number ('" + Utils.safeForLog(String.valueOf(raw)) + "') - using " + fallback + "."); return fallback; } long value = number.longValue(); if (value < min || value > max) { long clamp = Math.max(min, Math.min(max, value)); - plugin.getLogger().warning(path + " " + value + log().warning(path + " " + value + " is outside " + min + "-" + max + " - using " + clamp + "."); return (int) clamp; } @@ -247,7 +449,7 @@ static int parseRerollMaxPoints(ConfigurationSection config) { private void loadActivities(ConfigurationSection section) { if (section == null) { - plugin.getLogger().warning("config.yml has no 'activities' section - the bar can never fill."); + log().warning("config.yml has no 'activities' section - the bar can never fill."); activities = new LinkedHashMap<>(); craftActivities = new HashMap<>(); craftPaths = List.of(); @@ -268,14 +470,14 @@ private void loadActivities(ConfigurationSection section) { for (String id : section.getKeys(false)) { ConfigurationSection entry = section.getConfigurationSection(id); if (entry == null) { - plugin.getLogger().warning("Activity '" + id + "' is not a configuration section - it is" + log().warning("Activity '" + id + "' is not a configuration section - it is" + " dropped entirely and nothing will ever be credited to it."); continue; } int points = wholeNumber(entry, id, "points", 0); if (points <= 0) { - plugin.getLogger().warning("Activity '" + id + "' is worth " + points + log().warning("Activity '" + id + "' is worth " + points + " points - skipping it, since meeting its goal could never move the bar."); continue; } @@ -306,11 +508,11 @@ private void loadActivities(ConfigurationSection section) { String key = normalizeProfessionId(profession); String previous = professions.put(key, id); if (previous != null) { - plugin.getLogger().warning("Activities '" + previous + "' and '" + id + log().warning("Activities '" + previous + "' and '" + id + "' both track profession '" + key + "' - only '" + id + "' will be fed."); } if (dailyCap <= 0) { - plugin.getLogger().warning("Activity '" + id + "' tracks profession '" + key + log().warning("Activity '" + id + "' tracks profession '" + key + "' with no daily-cap - profession XP is unbounded, so this activity can" + " fill the bar on its own."); } @@ -324,7 +526,7 @@ private void loadActivities(ConfigurationSection section) { } if (guaranteed.size() > PlayerData.TASKS_PER_DAY) { - plugin.getLogger().warning(guaranteed.size() + " activities are marked daily-guaranteed but only " + log().warning(guaranteed.size() + " activities are marked daily-guaranteed but only " + PlayerData.TASKS_PER_DAY + " tasks are handed out a day - every draw is " + PlayerData.TASKS_PER_DAY + " of them picked at random and no other activity can" + " ever be drawn."); @@ -346,7 +548,7 @@ private void loadStation(Map stations, Map amou return; } if (station.isBlank()) { - plugin.getLogger().warning("Activity '" + id + "' has a malformed 'station': " + log().warning("Activity '" + id + "' has a malformed 'station': " + Utils.safeForLog(station) + " - expected or / - nothing will" + " ever feed that activity."); return; @@ -361,7 +563,7 @@ private void loadStation(Map stations, Map amou } } if (other != null) { - plugin.getLogger().warning("Activity '" + id + "' has both '" + other + "' and 'station' - an" + log().warning("Activity '" + id + "' has both '" + other + "' and 'station' - an" + " activity can only be fed by one source, so 'station' is ignored and '" + other + "' is kept."); return; @@ -369,7 +571,7 @@ private void loadStation(Map stations, Map amou String stationKey = stationKey(station); if (stationKey.isEmpty() || stationKey.startsWith("/") || stationKey.endsWith("/")) { - plugin.getLogger().warning("Activity '" + id + "' has a malformed 'station': " + log().warning("Activity '" + id + "' has a malformed 'station': " + Utils.safeForLog(station) + " - expected or / - nothing will" + " ever feed that activity."); return; @@ -378,7 +580,7 @@ private void loadStation(Map stations, Map amou String previous = stations.put(stationKey, id); amounts.remove(stationKey); if (previous != null) { - plugin.getLogger().warning("Activities '" + previous + "' and '" + id + log().warning("Activities '" + previous + "' and '" + id + "' both track station '" + stationKey + "' - only '" + id + "' will be fed."); } ConfigurationSection alternatives = entry.getConfigurationSection("station-actions"); @@ -392,7 +594,7 @@ private void loadStation(Map stations, Map amou if (key.isEmpty() || key.startsWith("/") || key.endsWith("/") || !key.contains("/") || key.equals(stationKey) || !Double.isFinite(value) || value < 1 || value > Integer.MAX_VALUE || Math.rint(value) != value) { - plugin.getLogger().warning("Activity '" + id + "' has invalid station-actions entry '" + log().warning("Activity '" + id + "' has invalid station-actions entry '" + Utils.safeForLog(recipe) + "' - expected a different /" + " with a positive whole-number amount within the int range."); continue; @@ -400,7 +602,7 @@ private void loadStation(Map stations, Map amou int amount = (int) value; String displaced = stations.put(key, id); if (displaced != null) { - plugin.getLogger().warning("Activities '" + displaced + "' and '" + id + log().warning("Activities '" + displaced + "' and '" + id + "' both track station '" + key + "' - only '" + id + "' will be fed."); } amounts.put(key, amount); @@ -427,7 +629,7 @@ private void loadCraft(Map crafts, List clickCommands(ConfigurationSection entry, String id) { return List.of(); } if (!(raw instanceof List list)) { - plugin.getLogger().warning("activities." + id + "." + CLICK_COMMANDS_KEY + " is not a list of" + log().warning("activities." + id + "." + CLICK_COMMANDS_KEY + " is not a list of" + " commands ('" + Utils.safeForLog(String.valueOf(raw)) + "') - no command will ever be run" + " for this activity."); return List.of(); @@ -538,7 +740,7 @@ private List description(ConfigurationSection entry, String id) { if (raw instanceof String text) { return text.isBlank() ? List.of() : List.of(text); } - plugin.getLogger().warning("activities." + id + "." + DESCRIPTION_KEY + " is neither a string nor a" + log().warning("activities." + id + "." + DESCRIPTION_KEY + " is neither a string nor a" + " list of lines ('" + Utils.safeForLog(String.valueOf(raw)) + "') - this activity shows no" + " description."); return List.of(); @@ -557,7 +759,7 @@ private static List nonBlank(List list) { private int barLength(int length) { if (length < 1 || length > 100) { int clamped = Math.max(1, Math.min(100, length)); - plugin.getLogger().warning("bar.length " + length + " is outside 1-100 - using " + clamped + "."); + log().warning("bar.length " + length + " is outside 1-100 - using " + clamped + "."); return clamped; } return length; @@ -596,7 +798,7 @@ private Map> loadRewardPools(ConfigurationSection conf if (named != null) { for (String key : keys(named)) { if (!isPoolName(key) || poolName(key).equals(DEFAULT_POOL)) { - plugin.getLogger().warning("rewards.pools." + Utils.safeForLog(key) + log().warning("rewards.pools." + Utils.safeForLog(key) + " must use a pool_ name - ignored."); continue; } @@ -617,7 +819,7 @@ private List loadRewardPool(ConfigurationSection config, String pat int weight = entry.get("weight") instanceof Number number ? number.intValue() : 1; if (weight <= 0) { - plugin.getLogger().warning(where + " has weight " + weight + " - a weight must be above 0," + log().warning(where + " has weight " + weight + " - a weight must be above 0," + " so this reward is skipped and can never be drawn."); continue; } @@ -633,7 +835,7 @@ private List loadRewardPool(ConfigurationSection config, String pat List items = loadRewardItems(entry.get("items"), where); if (commands.isEmpty() && items.isEmpty()) { - plugin.getLogger().warning(where + " has no commands and no items - skipped, since drawing it" + log().warning(where + " has no commands and no items - skipped, since drawing it" + " would pay the player nothing."); continue; } @@ -651,7 +853,7 @@ private List loadRewardItems(Object raw, String where) { return items; } if (!(raw instanceof List list)) { - plugin.getLogger().warning(where + ".items is not a list of 'item:'/'amount:' blocks ('" + log().warning(where + ".items is not a list of 'item:'/'amount:' blocks ('" + Utils.safeForLog(String.valueOf(raw)) + "') - no item is handed over for this entry."); return items; } @@ -659,12 +861,12 @@ private List loadRewardItems(Object raw, String where) { for (Object element : list) { String at = where + ".items[" + index++ + "]"; if (!(element instanceof Map map)) { - plugin.getLogger().warning(at + " is not an 'item:'/'amount:' block - ignored."); + log().warning(at + " is not an 'item:'/'amount:' block - ignored."); continue; } Object path = map.get("item"); if (path == null || String.valueOf(path).isBlank()) { - plugin.getLogger().warning(at + " has no 'item:' path - ignored."); + log().warning(at + " has no 'item:' path - ignored."); continue; } String value = String.valueOf(path).strip(); @@ -678,14 +880,14 @@ private List loadRewardItems(Object raw, String where) { private boolean validItemPath(String value, String at) { if (ItemPath.isUnsupportedPath(value)) { - plugin.getLogger().warning("Unsupported item path '" + Utils.safeForLog(value) + "' at " + at + log().warning("Unsupported item path '" + Utils.safeForLog(value) + "' at " + at + " - only bare Material names, v., m.. and ia." + " are supported - ignored."); return false; } if (ItemPath.isItemsAdderPath(value)) { if (ItemPath.itemsAdderId(value) == null) { - plugin.getLogger().warning("Malformed item path '" + Utils.safeForLog(value) + "' at " + at + log().warning("Malformed item path '" + Utils.safeForLog(value) + "' at " + at + " - expected ia. - ignored."); return false; } @@ -694,7 +896,7 @@ private boolean validItemPath(String value, String at) { } if (ItemPath.isPluginPath(value)) { if (ItemPath.pluginPath(value) == null) { - plugin.getLogger().warning("Malformed item path '" + Utils.safeForLog(value) + "' at " + at + log().warning("Malformed item path '" + Utils.safeForLog(value) + "' at " + at + " - expected m.. - ignored."); return false; } @@ -702,7 +904,7 @@ private boolean validItemPath(String value, String at) { return true; } if (ItemPath.material(value) == null) { - plugin.getLogger().warning("Unknown material '" + Utils.safeForLog(value) + "' at " + at + log().warning("Unknown material '" + Utils.safeForLog(value) + "' at " + at + " - ignored."); return false; } @@ -714,19 +916,19 @@ private int rewardAmount(Object raw, String at) { return 1; } if (!(raw instanceof Number number)) { - plugin.getLogger().warning(at + " has a non-numeric amount '" + Utils.safeForLog(String.valueOf(raw)) + log().warning(at + " has a non-numeric amount '" + Utils.safeForLog(String.valueOf(raw)) + "' - using 1."); return 1; } long amount = number.longValue(); if (number.doubleValue() != amount) { - plugin.getLogger().warning(at + " amount '" + Utils.safeForLog(String.valueOf(raw)) + log().warning(at + " amount '" + Utils.safeForLog(String.valueOf(raw)) + "' is not a whole number - using 1."); return 1; } if (amount < 1 || amount > 64) { long clamped = Math.max(1, Math.min(64, amount)); - plugin.getLogger().warning(at + " amount " + amount + " is outside 1-64 - using " + clamped + "."); + log().warning(at + " amount " + amount + " is outside 1-64 - using " + clamped + "."); return (int) clamped; } return (int) amount; @@ -739,13 +941,13 @@ Set loadMultiplierPools(ConfigurationSection config) { return Set.of(); } if (!(raw instanceof List entries)) { - plugin.getLogger().warning(path + " must be a list of pool names - no pools are multiplied."); + log().warning(path + " must be a list of pool names - no pools are multiplied."); return Set.of(); } Set names = new HashSet<>(); for (Object entry : entries) { if (!(entry instanceof String name) || !isPoolName(name)) { - plugin.getLogger().warning(path + " contains an invalid pool name - ignored."); + log().warning(path + " contains an invalid pool name - ignored."); continue; } names.add(poolName(name)); @@ -763,19 +965,19 @@ private int rewardMultiplier(ConfigurationSection config) { return 1; } if (!(raw instanceof Number number)) { - plugin.getLogger().warning(REWARDS_MULTIPLIER_PATH + " is not a number ('" + log().warning(REWARDS_MULTIPLIER_PATH + " is not a number ('" + Utils.safeForLog(String.valueOf(raw)) + "') - using 1."); return 1; } long value = number.longValue(); if (number.doubleValue() != value) { - plugin.getLogger().warning(REWARDS_MULTIPLIER_PATH + " '" + Utils.safeForLog(String.valueOf(raw)) + log().warning(REWARDS_MULTIPLIER_PATH + " '" + Utils.safeForLog(String.valueOf(raw)) + "' is not a whole number - using 1."); return 1; } if (value < 1 || value > 64) { long clamped = Math.max(1, Math.min(64, value)); - plugin.getLogger().warning(REWARDS_MULTIPLIER_PATH + " " + value + " is outside 1-64 - using " + log().warning(REWARDS_MULTIPLIER_PATH + " " + value + " is outside 1-64 - using " + clamped + "."); return (int) clamped; } @@ -786,7 +988,7 @@ private Map loadMilestoneDrops(ConfigurationSection config ConfigurationSection section = section(config, REWARDS_DROPS_PATH); if (section == null) { if (set(config, REWARDS_DROPS_PATH)) { - plugin.getLogger().warning(REWARDS_DROPS_PATH + " is not a section of drop_ keys - ignored," + log().warning(REWARDS_DROPS_PATH + " is not a section of drop_ keys - ignored," + " every milestone draws from the pool."); } return Map.of(); @@ -796,11 +998,11 @@ private Map loadMilestoneDrops(ConfigurationSection config String at = REWARDS_DROPS_PATH + "." + key; String number = key.startsWith("drop_") ? key.substring(5) : ""; if (!number.matches("[1-9][0-9]*")) { - plugin.getLogger().warning(at + " is not a drop_ key (N = 1, 2, ...) - ignored."); + log().warning(at + " is not a drop_ key (N = 1, 2, ...) - ignored."); continue; } if (number.length() > 9 || Integer.parseInt(number) > milestones.size()) { - plugin.getLogger().warning(at + " is past the last of the " + milestones.size() + log().warning(at + " is past the last of the " + milestones.size() + " bar.milestones - ignored."); continue; } @@ -826,7 +1028,7 @@ private Map loadMilestoneDrops(ConfigurationSection config private RewardEntry warnedPoolRef(String at, String value, String otherwise) { String pool = poolName(value); if (rewardPool(pool).isEmpty()) { - plugin.getLogger().warning(at + " draws from '" + Utils.safeForLog(pool) + "', which is not a pool" + log().warning(at + " draws from '" + Utils.safeForLog(pool) + "', which is not a pool" + " under rewards.pools or rewards, or has no usable entry - " + otherwise + "."); } return poolRef(pool); @@ -844,12 +1046,12 @@ private RewardEntry fixedItemString(String at, String value, String otherwise) { 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) { - plugin.getLogger().warning(at + " '" + Utils.safeForLog(value) + "' is not '' or" + log().warning(at + " '" + Utils.safeForLog(value) + "' is not '' or" + " ' ' - " + otherwise + "."); return null; } if (!validItemPath(parts[0], at)) { - plugin.getLogger().warning(at + " has no usable item path - " + otherwise + "."); + log().warning(at + " has no usable item path - " + otherwise + "."); return null; } return new RewardEntry(1, itemName(parts[0]), List.of(), List.of(new RewardEntry.Item(parts[0], amount))); @@ -858,7 +1060,7 @@ private RewardEntry fixedItemString(String at, String value, String otherwise) { private RewardEntry fixedItemBlock(String at, ConfigurationSection block, String otherwise) { String path = block.getString("item"); if (path == null || path.isBlank()) { - plugin.getLogger().warning(at + " has no 'item:' path - " + otherwise + "."); + log().warning(at + " has no 'item:' path - " + otherwise + "."); return null; } path = path.strip(); @@ -867,13 +1069,13 @@ private RewardEntry fixedItemBlock(String at, ConfigurationSection block, String String amountText = amountRaw == null ? "" : String.valueOf(amountRaw).strip(); int amount = amountRaw == null ? 1 : amountText.matches("[0-9]{1,2}") ? Integer.parseInt(amountText) : 0; if (amount < 1 || amount > 64) { - plugin.getLogger().warning(at + ".amount '" + Utils.safeForLog(amountText) + "' is not 1-64 - " + log().warning(at + ".amount '" + Utils.safeForLog(amountText) + "' is not 1-64 - " + otherwise + "."); return null; } if (!validItemPath(path, at)) { - plugin.getLogger().warning(at + " has no usable item path - " + otherwise + "."); + log().warning(at + " has no usable item path - " + otherwise + "."); return null; } @@ -884,7 +1086,7 @@ private RewardEntry fixedItemBlock(String at, ConfigurationSection block, String } } if (!unknown.isEmpty()) { - plugin.getLogger().warning(at + " has unknown key(s) " + Utils.safeForLog(String.join(", ", unknown)) + log().warning(at + " has unknown key(s) " + Utils.safeForLog(String.join(", ", unknown)) + " - ignored."); } @@ -895,7 +1097,7 @@ private Map loadDailyRewards(ConfigurationSection config) { ConfigurationSection section = section(config, DAILY_REWARD_GROUPS_PATH); if (section == null) { if (set(config, DAILY_REWARD_GROUPS_PATH)) { - plugin.getLogger().warning(DAILY_REWARD_GROUPS_PATH + " is not a section of : " + log().warning(DAILY_REWARD_GROUPS_PATH + " is not a section of : " + " lines - ignored, no daily reward is paid."); } return Map.of(); @@ -918,7 +1120,7 @@ private Map loadDailyRewards(ConfigurationSection config) { } } if (rewardPool().isEmpty() && groups.containsValue(DAILY_POOL)) { - plugin.getLogger().warning("rewards.pool is empty or every entry in it was dropped - a " + log().warning("rewards.pool is empty or every entry in it was dropped - a " + DAILY_REWARD_GROUPS_PATH + " group set to 'pool' can never be paid its daily reward."); } return Collections.unmodifiableMap(groups); @@ -945,7 +1147,7 @@ private List loadMilestones(List raw) { TreeSet milestones = new TreeSet<>(); for (Integer milestone : raw) { if (milestone == null || milestone < 1 || milestone > barMax) { - plugin.getLogger().warning("bar.milestones value " + milestone + " is outside 1-" + barMax + log().warning("bar.milestones value " + milestone + " is outside 1-" + barMax + " - ignored."); continue; } @@ -953,7 +1155,7 @@ private List loadMilestones(List raw) { } if (milestones.isEmpty()) { - plugin.getLogger().warning("bar.milestones is missing or has no usable value - falling back to" + log().warning("bar.milestones is missing or has no usable value - falling back to" + " 10 and 20, clipped to bar.max (" + barMax + ")."); for (int fallback : new int[] {10, 20}) { if (fallback <= barMax) { @@ -973,17 +1175,17 @@ private void warnIfVoteShareMisfits() { } ActivityDef vote = activities.get("vote"); if (vote == null) { - plugin.getLogger().warning("bar.vote-share is " + voteShare + " but there is no 'vote' activity" + log().warning("bar.vote-share is " + voteShare + " but there is no 'vote' activity" + " - the share is not applied."); return; } if (!guaranteedActivities.contains("vote")) { - plugin.getLogger().warning("bar.vote-share is " + voteShare + " but 'vote' is not daily-guaranteed" + log().warning("bar.vote-share is " + voteShare + " but 'vote' is not daily-guaranteed" + " - on a day it is not drawn, the reserved share cannot be earned."); } int reserved = dailyMax - nonVoteDailyMax(); if (vote.dailyCap() > 0 && vote.capPoints() < reserved) { - plugin.getLogger().warning("activities.vote.daily-cap " + vote.dailyCap() + " (" + vote.capPoints() + log().warning("activities.vote.daily-cap " + vote.dailyCap() + " (" + vote.capPoints() + " points) is below the " + reserved + " points bar.vote-share keeps for voting - the rest of that share is never earned."); } @@ -1001,7 +1203,7 @@ private void warnIfBarUnreachable() { String warning = unreachableWarning(caps, activities.size(), shared ? nonVoteDailyMax() : dailyMax, milestones.get(0)); if (warning != null) { - plugin.getLogger().warning(shared ? warning + " This is without voting: bar.vote-share keeps " + log().warning(shared ? warning + " This is without voting: bar.vote-share keeps " + (dailyMax - nonVoteDailyMax()) + " of bar.daily-max for it." : warning); } } @@ -1043,7 +1245,7 @@ private String iconPath(String name, String path) { if (ItemPath.isItemsAdderPath(name)) { String id = ItemPath.itemsAdderId(name); if (id == null) { - plugin.getLogger().warning("Malformed item path '" + Utils.safeForLog(name) + "' at " + path + log().warning("Malformed item path '" + Utils.safeForLog(name) + "' at " + path + " - expected ia. - using PAPER."); return null; } @@ -1055,7 +1257,7 @@ private String iconPath(String name, String path) { } String resolved = ItemPath.pluginPath(name); if (resolved == null) { - plugin.getLogger().warning("Malformed item path '" + Utils.safeForLog(name) + "' at " + path + log().warning("Malformed item path '" + Utils.safeForLog(name) + "' at " + path + " - expected m.. - using PAPER."); return null; } @@ -1065,14 +1267,14 @@ private String iconPath(String name, String path) { private Material material(String name, String path) { if (ItemPath.isUnsupportedPath(name)) { - plugin.getLogger().warning("Unsupported item path '" + Utils.safeForLog(name) + "' at " + path + log().warning("Unsupported item path '" + Utils.safeForLog(name) + "' at " + path + " - only bare Material names, v., m.. and ia." + " are supported - using PAPER."); return Material.PAPER; } Material material = ItemPath.material(name); if (material == null) { - plugin.getLogger().warning("Unknown material '" + name + "' at " + path + " - using PAPER."); + log().warning("Unknown material '" + name + "' at " + path + " - using PAPER."); return Material.PAPER; } return material; @@ -1082,7 +1284,7 @@ private DayOfWeek parseDay(String name) { try { return DayOfWeek.valueOf(name.trim().toUpperCase(Locale.ROOT)); } catch (IllegalArgumentException e) { - plugin.getLogger().warning("Unknown reset.day '" + name + "' - using MONDAY."); + log().warning("Unknown reset.day '" + name + "' - using MONDAY."); return DayOfWeek.MONDAY; } } diff --git a/src/main/java/net/tfminecraft/activitytf/store/PlayerStore.java b/src/main/java/net/tfminecraft/activitytf/store/PlayerStore.java index 37c6476..cfc8082 100644 --- a/src/main/java/net/tfminecraft/activitytf/store/PlayerStore.java +++ b/src/main/java/net/tfminecraft/activitytf/store/PlayerStore.java @@ -171,6 +171,7 @@ public PlayerData get(UUID uuid) { } ActivityConfiguration.Keys keys = config.currentKeys(); + config.rollRewardWeek(keys.week()); data = new PlayerData(keys.week(), keys.day()); players.put(uuid, data); dirty = true; @@ -184,6 +185,7 @@ public PlayerData rolled(UUID uuid) { } ActivityConfiguration.Keys keys = config.currentKeys(); + config.rollRewardWeek(keys.week()); if (data.roll(keys.week(), keys.day()) | data.clamp(config.barMax())) { dirty = true; } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 439f586..de12872 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -732,13 +732,21 @@ activities: # commands below still pays it out. The same goes for an empty pool. To reward # Bedrock/Floodgate or other unsafe names, write reward commands using only # %uuid% (e.g. via a plugin that can look a player up by UUID). +# Rewards are locked per week. The first load of each week copies this +# rewards section, bar.milestones and daily-reward.groups to +# reward-lock.yml, and the whole week pays from that copy. A reload or +# restart only stages new values here; they apply at the next weekly +# reset, so an act change can be set up mid-week without changing what +# this week's milestones pay. /activity rewards shows the locked week and +# whether changes are waiting; /activity rewards apply reloads and uses +# them right away. rewards: # Spins per whitelisted pool milestone: at 2, it draws from the pool # twice, independently - the same entry can come up both times - and # pays each draw's items and commands at the amounts written, with one chat # line per draw. A fixed drop has nothing to spin and hands over its amount - # times this instead. Read at payout, so a reload changes what the next - # claim pays. 1-64; anything outside that is clamped, and 0 or a negative + # times this instead. Locked with the week's rewards, so a change applies + # from the next weekly reset. 1-64; anything outside that is clamped, and 0 or a negative # reads as 1 rather than as "pay nothing". A total above one stack is # handed over as whole stacks of the item's own maximum size. multiplier: 1 diff --git a/src/main/resources/messages.yml b/src/main/resources/messages.yml index 7031f73..0a1b091 100644 --- a/src/main/resources/messages.yml +++ b/src/main/resources/messages.yml @@ -34,11 +34,16 @@ gui: filler-name: "&8 " placeholder: { done: "&a✔", not-done: "&c✘" } admin: - usage: "&eUsage: /activity |reset |givereroll |add [--force]>" + usage: "&eUsage: /activity |reset |givereroll |add [--force]>" usage-addpoints: "&eUsage: /activity addpoints " no-permission: "&cNo permission." players-only: "&cPlayers only." reloaded: "&aactivity reloaded." + rewards-locked: "&eWeekly rewards are locked for the week of &f%week%&e." + rewards-in-sync: "&aconfig.yml has the same rewards - nothing is waiting for the reset." + rewards-pending: "&econfig.yml has different rewards: they apply at the weekly reset (&f%reset%&e). Use &f/activity rewards apply&e to use them now." + rewards-applied: "&aconfig.yml's rewards now apply to the week of &f%week%&a." + rewards-apply-failed: "&cCould not save reward-lock.yml - this week keeps its locked rewards. See the console." unknown-player: "&cUnknown player: %player%" unknown-activity: "&cUnknown activity: %activity%" invalid-number: "&cNot a number: %value%" diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index ada98cc..c381666 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -9,7 +9,7 @@ softdepend: [VotingPlugin, MusicalInstruments, geiger_counter, MMOCore, MMOItems commands: activity: description: Open the weekly activity bar, or run admin subcommands - usage: /activity [reload|check |reset |givereroll |add [--force]|addpoints ] + usage: /activity [reload|rewards [apply]|check |reset |givereroll |add [--force]|addpoints ] permissions: activity.use: description: Open the weekly activity GUI diff --git a/src/test/java/net/tfminecraft/activitytf/commands/ActivityAdminCommandTest.java b/src/test/java/net/tfminecraft/activitytf/commands/ActivityAdminCommandTest.java index 2b930b1..4325ffb 100644 --- a/src/test/java/net/tfminecraft/activitytf/commands/ActivityAdminCommandTest.java +++ b/src/test/java/net/tfminecraft/activitytf/commands/ActivityAdminCommandTest.java @@ -911,8 +911,10 @@ void anAdminIsOfferedEverySubcommand() { ActivityManager manager = manager(); ActivityCommand command = command(manager, UUID.randomUUID(), "Steve"); - assertEquals(List.of("reload", "check", "reset", "givereroll", "add", "addpoints"), + assertEquals(List.of("reload", "rewards", "check", "reset", "givereroll", "add", "addpoints"), command.onTabComplete(admin().bukkit, null, "activity", new String[] {""})); + assertEquals(List.of("apply"), + command.onTabComplete(admin().bukkit, null, "activity", new String[] {"rewards", ""})); assertEquals(List.of("Steve"), command.onTabComplete(admin().bukkit, null, "activity", new String[] {"addpoints", "Ste"})); assertEquals(List.of(), @@ -921,6 +923,69 @@ void anAdminIsOfferedEverySubcommand() { command.onTabComplete(admin().bukkit, null, "activity", new String[] {"givereroll", "Ste"})); } + private static void lockedWeek(ActivityManager manager, String week, boolean pending) { + try { + Field field = manager.getConfiguration().getClass().getDeclaredField("rewardWeek"); + field.setAccessible(true); + field.set(manager.getConfiguration(), week); + field = manager.getConfiguration().getClass().getDeclaredField("rewardsPending"); + field.setAccessible(true); + field.setBoolean(manager.getConfiguration(), pending); + } catch (ReflectiveOperationException e) { + throw new RuntimeException(e); + } + } + + @Test + void rewardsShowsTheLockedWeek() { + ActivityManager manager = manager(); + lockedWeek(manager, "2026-10-05", false); + + Sender sender = admin(); + command(manager, UUID.randomUUID(), "Steve").onCommand( + sender.bukkit, null, "activity", new String[] {"rewards"}); + + assertEquals("Weekly rewards are locked for the week of 2026-10-05.\n" + + "config.yml has the same rewards - nothing is waiting for the reset.", sender.all()); + } + + @Test + void rewardsSaysWhenConfigChangesWaitForTheReset() { + ActivityManager manager = manager(); + lockedWeek(manager, "2026-10-05", true); + + Sender sender = admin(); + command(manager, UUID.randomUUID(), "Steve").onCommand( + sender.bukkit, null, "activity", new String[] {"rewards"}); + + assertTrue(sender.all().endsWith("config.yml has different rewards: they apply at the weekly reset" + + " (2026-10-12 00:00). Use /activity rewards apply to use them now."), sender.all()); + } + + @Test + void rewardsWithAnUnknownArgumentShowsUsage() { + ActivityManager manager = manager(); + lockedWeek(manager, "2026-10-05", true); + + Sender sender = admin(); + List lines = audit(() -> command(manager, UUID.randomUUID(), "Steve").onCommand( + sender.bukkit, null, "activity", new String[] {"rewards", "now"})); + + assertTrue(sender.all().startsWith("Usage: /activity logged = new ArrayList<>(); + private JavaPlugin plugin; + private ActivityConfiguration config; + private File lock; + + @BeforeEach + void setUp() throws ReflectiveOperationException { + plugin = TestPlugins.capturing(logged); + Field dataFolder = JavaPlugin.class.getDeclaredField("dataFolder"); + dataFolder.setAccessible(true); + dataFolder.set(plugin, dir.toFile()); + + config = new ActivityConfiguration(plugin); + set("barMax", 50); + set("resetHour", 0); + set("resetDay", java.time.DayOfWeek.MONDAY); + lock = dir.resolve(ActivityConfiguration.REWARD_LOCK_FILE).toFile(); + } + + private void set(String field, Object value) throws ReflectiveOperationException { + Field f = ActivityConfiguration.class.getDeclaredField(field); + f.setAccessible(true); + f.set(config, value); + } + + private static YamlConfiguration yaml(String content) { + return YamlConfiguration.loadConfiguration(new StringReader(content)); + } + + private void stageAndLock(String content, String week) { + config.stageRewards(yaml(content)); + config.lockRewards(lock, week, false); + } + + private String firstItem(String pool) { + List entries = config.rewardPool(pool); + return entries.isEmpty() ? null : entries.get(0).items().get(0).path(); + } + + private boolean loggedContains(String fragment) { + return logged.stream().anyMatch(message -> message != null && message.contains(fragment)); + } + + @Test + void theFirstLoadLocksConfigRewardsForTheWeek() { + stageAndLock(PROLOGUE, WEEK); + + assertTrue(lock.isFile()); + YamlConfiguration written = YamlConfiguration.loadConfiguration(lock); + assertEquals(WEEK, written.getString("week")); + assertEquals(2, written.getInt("rewards-config.rewards.multiplier")); + assertEquals(1, written.getMapList("rewards-config.rewards.pool_prologue").size()); + assertEquals(List.of(10, 20, 30), written.getIntegerList("rewards-config.bar.milestones")); + assertEquals(WEEK, config.rewardWeek()); + assertFalse(config.rewardsPending()); + assertEquals("IRON_INGOT", firstItem("pool_prologue")); + } + + @Test + void aReloadInTheSameWeekKeepsTheLockedRewards() { + stageAndLock(PROLOGUE, WEEK); + logged.clear(); + + stageAndLock(ACT_ONE, WEEK); + + assertTrue(config.rewardsPending()); + assertEquals("IRON_INGOT", firstItem("pool_prologue")); + assertTrue(config.rewardPool("pool_act1").isEmpty()); + assertEquals(2, config.rewardMultiplier()); + assertEquals(List.of(10, 20, 30), config.milestones()); + assertEquals("pool_prologue", ActivityConfiguration.referencedPool(config.milestoneDrops().get(10))); + assertEquals("pool_prologue", ActivityConfiguration.referencedPool(config.dailyRewards().get("default"))); + assertTrue(loggedContains("differ from the ones locked for the week of " + WEEK)); + assertTrue(loggedContains(NEXT_WEEK + " 00:00")); + assertEquals(WEEK, YamlConfiguration.loadConfiguration(lock).getString("week")); + } + + @Test + void theLockedParseDoesNotRepeatConfigWarnings() { + stageAndLock(PROLOGUE.replace("IRON_INGOT", "NOT_A_MATERIAL"), WEEK); + assertTrue(loggedContains("NOT_A_MATERIAL")); + logged.clear(); + + stageAndLock(ACT_ONE, WEEK); + + assertFalse(loggedContains("NOT_A_MATERIAL")); + } + + @Test + void theNextWeekSwitchesToTheStagedRewards() { + stageAndLock(PROLOGUE, WEEK); + stageAndLock(ACT_ONE, WEEK); + logged.clear(); + + config.lockRewards(lock, NEXT_WEEK, false); + + assertFalse(config.rewardsPending()); + assertEquals(NEXT_WEEK, config.rewardWeek()); + assertEquals("GOLD_INGOT", firstItem("pool_act1")); + assertEquals(3, config.rewardMultiplier()); + assertEquals(List.of(10, 20, 40), config.milestones()); + assertEquals(NEXT_WEEK, YamlConfiguration.loadConfiguration(lock).getString("week")); + assertTrue(loggedContains("New activity week " + NEXT_WEEK)); + } + + @Test + void rollRewardWeekLocksOnceTheWeekChanges() { + stageAndLock(PROLOGUE, WEEK); + stageAndLock(ACT_ONE, WEEK); + + config.rollRewardWeek(WEEK); + assertTrue(config.rewardsPending()); + + config.rollRewardWeek(NEXT_WEEK); + assertEquals(NEXT_WEEK, config.rewardWeek()); + assertEquals("GOLD_INGOT", firstItem("pool_act1")); + } + + @Test + void rollRewardWeekDoesNothingBeforeTheFirstLoad() { + config.rollRewardWeek(WEEK); + + assertNull(config.rewardWeek()); + assertFalse(lock.exists()); + } + + @Test + void applyNowUsesTheStagedRewardsThisWeek() { + stageAndLock(PROLOGUE, WEEK); + config.stageRewards(yaml(ACT_ONE)); + + assertTrue(config.lockRewards(lock, WEEK, true)); + + assertFalse(config.rewardsPending()); + assertEquals("GOLD_INGOT", firstItem("pool_act1")); + + // A later reload in the same week keeps the applied rewards. + stageAndLock(ACT_ONE, WEEK); + assertFalse(config.rewardsPending()); + assertEquals("GOLD_INGOT", firstItem("pool_act1")); + } + + @Test + void applyNowKeepsTheLockWhenItCannotBeSaved() throws IOException { + stageAndLock(PROLOGUE, WEEK); + stageAndLock(ACT_ONE, WEEK); + File blocked = dir.resolve("blocked").toFile(); + Files.createDirectories(blocked.toPath().resolve(ActivityConfiguration.REWARD_LOCK_FILE)); + + assertFalse(config.lockRewards(new File(blocked, ActivityConfiguration.REWARD_LOCK_FILE), WEEK, true)); + + assertEquals("IRON_INGOT", firstItem("pool_prologue")); + assertTrue(loggedContains("Could not save " + ActivityConfiguration.REWARD_LOCK_FILE)); + } + + @Test + void aLockWithoutAWeekIsReplaced() throws IOException { + Files.writeString(lock.toPath(), "something: else\n"); + logged.clear(); + + stageAndLock(PROLOGUE, WEEK); + + assertTrue(loggedContains("has no week or rewards-config")); + assertEquals(WEEK, YamlConfiguration.loadConfiguration(lock).getString("week")); + assertEquals("IRON_INGOT", firstItem("pool_prologue")); + } + + @Test + void identicalRewardsAreNotPending() { + stageAndLock(PROLOGUE, WEEK); + + stageAndLock(PROLOGUE, WEEK); + + assertFalse(config.rewardsPending()); + } + + @Test + void theSnapshotIgnoresJarDefaultRewards() { + YamlConfiguration live = yaml("bar:\n max: 50\n"); + live.setDefaults(yaml(PROLOGUE)); + + YamlConfiguration snapshot = ActivityConfiguration.rewardSnapshot(live); + + assertFalse(snapshot.contains("rewards")); + assertFalse(snapshot.contains("daily-reward.groups")); + assertEquals(List.of(10, 20, 30), snapshot.getIntegerList("bar.milestones")); + } + + @Test + void theSnapshotKeepsFixedDropBlocks() { + stageAndLock(PROLOGUE.replace("drop_3: pool_skin", "drop_3:\n item: PAPER\n amount: 3"), WEEK); + stageAndLock(ACT_ONE, WEEK); + + RewardEntry fixed = config.milestoneDrops().get(30); + assertEquals(List.of(new RewardEntry.Item("PAPER", 3)), fixed.items()); + } + + @Test + void nextResetIsOneWeekAfterTheLockedWeek() throws ReflectiveOperationException { + set("resetHour", 6); + + assertEquals("2026-10-12 06:00", config.nextReset(WEEK)); + } + + @Test + void activeRewardsReflectTheLockedTable() { + stageAndLock(PROLOGUE, WEEK); + stageAndLock(ACT_ONE, WEEK); + + ActivityConfiguration.RewardTable table = config.activeRewards(); + assertEquals(java.util.Set.of("pool_prologue", "pool_skin"), table.pools().keySet()); + assertEquals(2, table.multiplier()); + assertEquals(java.util.Set.of("pool_prologue"), table.multiplierPools()); + } +}