diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java index 694aba5d9..c0508225b 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java @@ -938,9 +938,9 @@ public void execute(CommandSender sender, String[] args) { } if (plugin.getVoteStreakHandler().resetVoteStreak(user, target)) { - sendMessage(sender, "&cReset VoteStreak '" + target + "' for '" + args[1] + "'"); + sendMessage(sender, "&cReset VoteStreak state '" + target + "' for '" + args[1] + "'"); } else { - sendMessage(sender, "&cVoteStreak not found: &e" + target); + sendMessage(sender, "&cVoteStreak or progress group not found: &e" + target); } } }); @@ -2826,6 +2826,7 @@ public void updateReplacements() { for (VoteStreakDefinition def : plugin.getVoteStreakHandler().getDefinitions()) { voteStreaks.add(def.getId()); } + voteStreaks.addAll(plugin.getVoteStreakHandler().getProgressGroups()); TabCompleteHandler.getInstance().addTabCompleteOption(new TabCompleteHandle("(votestreak)", voteStreaks) { @@ -2835,6 +2836,7 @@ public void reload() { for (VoteStreakDefinition def : plugin.getVoteStreakHandler().getDefinitions()) { voteStreaks.add(def.getId()); } + voteStreaks.addAll(plugin.getVoteStreakHandler().getProgressGroups()); setReplace(voteStreaks); } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/votestreak/VoteStreakDefinition.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/votestreak/VoteStreakDefinition.java index fb6f47633..31fc38058 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/votestreak/VoteStreakDefinition.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/votestreak/VoteStreakDefinition.java @@ -23,9 +23,25 @@ public final class VoteStreakDefinition { @Getter private final boolean recurring; + @Getter + private final String progressGroup; + @Getter + private final String rewardPath; public VoteStreakDefinition(String id, VoteStreakType type, boolean enabled, int requiredAmount, int votesRequired, int allowMissedAmount, int allowMissedPeriod, boolean recurring) { + this(id, type, enabled, requiredAmount, votesRequired, allowMissedAmount, allowMissedPeriod, recurring, "", + "VoteStreaks." + id + ".Rewards"); + } + + public VoteStreakDefinition(String id, VoteStreakType type, boolean enabled, int requiredAmount, int votesRequired, + int allowMissedAmount, int allowMissedPeriod, boolean recurring, String progressGroup) { + this(id, type, enabled, requiredAmount, votesRequired, allowMissedAmount, allowMissedPeriod, recurring, + progressGroup, "VoteStreaks." + id + ".Rewards"); + } + + public VoteStreakDefinition(String id, VoteStreakType type, boolean enabled, int requiredAmount, int votesRequired, + int allowMissedAmount, int allowMissedPeriod, boolean recurring, String progressGroup, String rewardPath) { this.id = id; this.type = type; this.enabled = enabled; @@ -34,6 +50,9 @@ public VoteStreakDefinition(String id, VoteStreakType type, boolean enabled, int this.allowMissedPeriod = Math.max(0, allowMissedPeriod); this.votesRequired = Math.max(1, votesRequired); this.recurring = recurring; + this.progressGroup = progressGroup == null ? "" : progressGroup.trim(); + this.rewardPath = rewardPath == null || rewardPath.trim().isEmpty() ? "VoteStreaks." + id + ".Rewards" + : rewardPath.trim(); } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/votestreak/VoteStreakHandler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/votestreak/VoteStreakHandler.java index 61e954ad1..7aff4ef5f 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/votestreak/VoteStreakHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/votestreak/VoteStreakHandler.java @@ -8,10 +8,12 @@ import java.time.temporal.WeekFields; import java.util.ArrayList; import java.util.Collections; +import java.util.LinkedHashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Set; import java.util.UUID; import java.util.regex.Pattern; @@ -35,6 +37,7 @@ public class VoteStreakHandler { @Getter private final Map byId = new LinkedHashMap<>(); + private final Map byProgressGroup = new LinkedHashMap<>(); private final List ordered = new ArrayList<>(); public VoteStreakHandler(VotingPluginMain plugin) { @@ -46,6 +49,7 @@ public VoteStreakHandler(VotingPluginMain plugin) { */ public void reload() { byId.clear(); + byProgressGroup.clear(); ordered.clear(); // VoteStreak definitions live in specialrewards.yml now (Option B) @@ -64,6 +68,14 @@ public VoteStreakDefinition getDefinition(String id) { return byId.get(id.toLowerCase(Locale.ROOT)); } + public Set getProgressGroups() { + Set groups = new LinkedHashSet<>(); + for (VoteStreakDefinition def : byProgressGroup.values()) { + groups.add(def.getProgressGroup()); + } + return Collections.unmodifiableSet(groups); + } + /** * Call this whenever a vote happens * @@ -84,6 +96,8 @@ public void processVote(VotingPluginUser user, long voteTimeMillis, UUID voteUUI plugin.extraDebug("[VoteStreak] processVote: user=" + safeUser(user) + " timeMillis=" + voteTimeMillis + " defs=" + ordered.size()); + Map> grouped = new LinkedHashMap<>(); + for (VoteStreakDefinition def : ordered) { if (!def.isEnabled()) { plugin.extraDebug( @@ -91,10 +105,15 @@ public void processVote(VotingPluginUser user, long voteTimeMillis, UUID voteUUI continue; } + grouped.computeIfAbsent(getColumnName(def), key -> new ArrayList<>()).add(def); + } + + for (List defs : grouped.values()) { try { - processVoteForDefinition(user, def, voteTimeMillis, voteUUID); + processVoteForDefinitions(user, defs, voteTimeMillis, voteUUID); } catch (Exception e) { - plugin.getLogger().warning("VoteStreak processing failed for '" + def.getId() + "': " + e.getMessage()); + plugin.getLogger().warning( + "VoteStreak processing failed for '" + defs.get(0).getId() + "': " + e.getMessage()); plugin.debug(e); } } @@ -141,17 +160,29 @@ public int migrateLegacyConfigManually() { private void processVoteForDefinition(VotingPluginUser user, VoteStreakDefinition def, long voteTimeMillis, UUID voteUUID) { - final String col = getColumnName(def); + processVoteForDefinitions(user, Collections.singletonList(def), voteTimeMillis, voteUUID); + } + + private void processVoteForDefinitions(VotingPluginUser user, List defs, long voteTimeMillis, + UUID voteUUID) { + if (defs == null || defs.isEmpty()) { + return; + } + + VoteStreakDefinition progressDef = defs.get(0); + final boolean sharedProgress = progressDef.getProgressGroup() != null && !progressDef.getProgressGroup().isEmpty(); + final String col = getColumnName(progressDef); final String rawBefore = readStateString(user, col); StreakState state = StreakState.deserialize(rawBefore); - final String currentPeriodKey = periodKey(def.getType(), voteTimeMillis); - migrateLegacyProgressIfNeeded(user, def, state, currentPeriodKey); + final String currentPeriodKey = periodKey(progressDef.getType(), voteTimeMillis); + migrateLegacyProgressIfNeeded(user, progressDef, state, currentPeriodKey); - plugin.extraDebug("[VoteStreak] def=" + def.getId() + " idKey=" + def.getId() + " type=" + def.getType() - + " col=" + col + " period=" + currentPeriodKey + " votesReq=" + def.getVotesRequired() + " interval=" - + def.getRequiredAmount() + " rawBefore='" + rawBefore + "' stateBefore={periodKey=" + state.periodKey - + ",streakCount=" + state.streakCount + ",votesThisPeriod=" + state.votesThisPeriod + plugin.extraDebug("[VoteStreak] def=" + progressDef.getId() + " idKey=" + progressDef.getId() + " type=" + + progressDef.getType() + " col=" + col + " progressGroup=" + progressDef.getProgressGroup() + + " period=" + currentPeriodKey + " votesReq=" + progressDef.getVotesRequired() + " interval=" + + progressDef.getRequiredAmount() + " rawBefore='" + rawBefore + "' stateBefore={periodKey=" + + state.periodKey + ",streakCount=" + state.streakCount + ",votesThisPeriod=" + state.votesThisPeriod + ",countedThisPeriod=" + state.countedThisPeriod + ",missesUsed=" + state.missesUsed + ",missWindowStartKey=" + state.missWindowStartKey + "}"); @@ -165,7 +196,7 @@ private void processVoteForDefinition(VotingPluginUser user, VoteStreakDefinitio state.missesUsed = 0; } else if (!state.periodKey.equals(currentPeriodKey)) { plugin.extraDebug("[VoteStreak] period advanced from " + state.periodKey + " -> " + currentPeriodKey); - advancePeriodsAndApplyMisses(def, state, voteTimeMillis, currentPeriodKey); + advancePeriodsAndApplyMisses(progressDef, state, voteTimeMillis, currentPeriodKey); plugin.extraDebug("[VoteStreak] after advance state={periodKey=" + state.periodKey + ",streakCount=" + state.streakCount + ",votesThisPeriod=" + state.votesThisPeriod + ",countedThisPeriod=" + state.countedThisPeriod + ",missesUsed=" + state.missesUsed + ",missWindowStartKey=" @@ -177,7 +208,7 @@ private void processVoteForDefinition(VotingPluginUser user, VoteStreakDefinitio if (!state.countedThisPeriod) { state.votesThisPeriod++; - int votesRequired = Math.max(1, def.getVotesRequired()); + int votesRequired = Math.max(1, progressDef.getVotesRequired()); plugin.extraDebug("[VoteStreak] vote counted in period: votesThisPeriod=" + state.votesThisPeriod + "/" + votesRequired); @@ -185,15 +216,20 @@ private void processVoteForDefinition(VotingPluginUser user, VoteStreakDefinitio state.countedThisPeriod = true; state.streakCount++; - int interval = Math.max(1, def.getRequiredAmount()); - boolean shouldReward = shouldReward(def, state.streakCount); + for (VoteStreakDefinition def : defs) { + int interval = Math.max(1, def.getRequiredAmount()); + boolean shouldReward = shouldReward(def, state, sharedProgress); - plugin.extraDebug("[VoteStreak] period satisfied: streakCount=" + state.streakCount + " interval=" - + interval + " shouldReward=" + shouldReward); + plugin.extraDebug("[VoteStreak] period satisfied: def=" + def.getId() + " streakCount=" + + state.streakCount + " interval=" + interval + " shouldReward=" + shouldReward); - if (shouldReward) { - plugin.extraDebug("[VoteStreak] giving rewards for idKey=" + def.getId()); - giveRewards(user, def, voteUUID, state.streakCount); + if (shouldReward) { + plugin.extraDebug("[VoteStreak] giving rewards for idKey=" + def.getId()); + giveRewards(user, def, voteUUID, state.streakCount); + if (sharedProgress && !def.isRecurring()) { + state.markRewarded(def); + } + } } } } else { @@ -211,6 +247,13 @@ private void processVoteForDefinition(VotingPluginUser user, VoteStreakDefinitio + rawAfter.length() + " readLen=" + readBack.length() + ")"); } + private boolean shouldReward(VoteStreakDefinition def, StreakState state, boolean sharedProgress) { + if (!shouldReward(def, state.streakCount)) { + return false; + } + return !sharedProgress || def.isRecurring() || !state.hasRewarded(def); + } + private boolean shouldReward(VoteStreakDefinition def, int streakCount) { if (streakCount <= 0) { return false; @@ -457,9 +500,12 @@ public int resetVoteStreaks(VotingPluginUser user) { } int reset = 0; + Set resetColumns = new LinkedHashSet<>(); for (VoteStreakDefinition def : ordered) { - writeStateString(user, getColumnName(def), ""); - reset++; + if (resetColumns.add(getColumnName(def))) { + writeStateString(user, getColumnName(def), ""); + reset++; + } } return reset; } @@ -470,8 +516,9 @@ public int resetVoteStreaks(VotingPluginUser user, VoteStreakType type) { } int reset = 0; + Set resetColumns = new LinkedHashSet<>(); for (VoteStreakDefinition def : ordered) { - if (def.getType() == type) { + if (def.getType() == type && resetColumns.add(getColumnName(def))) { writeStateString(user, getColumnName(def), ""); reset++; } @@ -484,10 +531,20 @@ public boolean resetVoteStreak(VotingPluginUser user, String id) { return false; } - VoteStreakDefinition def = getDefinition(id); + String target = id.trim().toLowerCase(Locale.ROOT); + VoteStreakDefinition progressGroup = byProgressGroup.get(target); + if (progressGroup != null) { + writeStateString(user, getColumnName(progressGroup), ""); + return true; + } + + VoteStreakDefinition def = getDefinition(target); if (def == null) { return false; } + if (def.getProgressGroup() != null && !def.getProgressGroup().isEmpty()) { + return false; + } writeStateString(user, getColumnName(def), ""); return true; @@ -502,12 +559,15 @@ private void giveRewards(VotingPluginUser user, VoteStreakDefinition def, UUID v if (event.isCancelled()) { return; } - new RewardBuilder(plugin.getSpecialRewardsConfig().getData(), "VoteStreaks." + def.getId() + ".Rewards") + new RewardBuilder(plugin.getSpecialRewardsConfig().getData(), def.getRewardPath()) .withPlaceHolder("id", def.getId()).withPlaceHolder("type", def.getType().toString()) .withPlaceHolder("amount", "" + streakCount).withPlaceHolder("streak", "" + streakCount).send(user); } public String getColumnName(VoteStreakDefinition def) { + if (def.getProgressGroup() != null && !def.getProgressGroup().isEmpty()) { + return "VoteStreakGroup_" + def.getType().name() + "_" + def.getProgressGroup(); + } return "VoteStreak_" + def.getId(); } @@ -551,10 +611,13 @@ private static final class StreakState { boolean countedThisPeriod = false; String missWindowStartKey = ""; int missesUsed = 0; + Set rewardedDefinitions = new LinkedHashSet<>(); String serialize() { - return safe(periodKey) + "|" + streakCount + "|" + votesThisPeriod + "|" + countedThisPeriod + "|" + String base = safe(periodKey) + "|" + streakCount + "|" + votesThisPeriod + "|" + countedThisPeriod + "|" + safe(missWindowStartKey) + "|" + missesUsed; + String rewarded = serializeRewardedDefinitions(); + return rewarded.isEmpty() ? base : base + "|" + rewarded; } static StreakState deserialize(String raw) { @@ -593,6 +656,14 @@ static StreakState deserialize(String raw) { s.votesThisPeriod = s.countedThisPeriod ? 1 : 0; } + if (p.length > 6) { + for (String rewarded : p[6].split(",")) { + if (rewarded != null && !rewarded.trim().isEmpty()) { + s.rewardedDefinitions.add(rewarded.trim().toLowerCase(Locale.ROOT)); + } + } + } + if (s.periodKey == null) s.periodKey = ""; if (s.missWindowStartKey == null) @@ -607,6 +678,21 @@ static StreakState deserialize(String raw) { return s; } + boolean hasRewarded(VoteStreakDefinition def) { + return rewardedDefinitions.contains(def.getId().toLowerCase(Locale.ROOT)); + } + + void markRewarded(VoteStreakDefinition def) { + rewardedDefinitions.add(def.getId().toLowerCase(Locale.ROOT)); + } + + private String serializeRewardedDefinitions() { + if (rewardedDefinitions.isEmpty()) { + return ""; + } + return String.join(",", rewardedDefinitions); + } + private static String safe(String s) { return s == null ? "" : s; } @@ -629,6 +715,7 @@ private static boolean parseBool(String s, boolean def) { public final class VoteStreakConfigLoader { private final Pattern idPattern = Pattern.compile("^[A-Za-z0-9_\\-]+$"); // no spaces + private final Pattern progressGroupPattern = Pattern.compile("^[A-Za-z0-9_\\-]+$"); private boolean loadLegacy(ConfigurationSection root) { ConfigurationSection legacy = root.getConfigurationSection("VoteStreak"); @@ -747,6 +834,10 @@ public void load(ConfigurationSection root) { for (String id : voteStreaks.getKeys(false)) { count++; + if ("ProgressGroups".equalsIgnoreCase(id)) { + continue; + } + if (id == null || id.trim().isEmpty()) { plugin.getLogger().warning("VoteStreaks entry #" + count + " has empty key; skipping."); continue; @@ -815,7 +906,135 @@ public void load(ConfigurationSection root) { ordered.add(def); } + loadProgressGroups(voteStreaks); + plugin.getLogger().info("Loaded " + ordered.size() + " VoteStreak definitions."); } + + private void loadProgressGroups(ConfigurationSection voteStreaks) { + ConfigurationSection progressGroups = voteStreaks.getConfigurationSection("ProgressGroups"); + if (progressGroups == null) { + return; + } + + for (String groupId : progressGroups.getKeys(false)) { + if (groupId == null || groupId.trim().isEmpty()) { + plugin.getLogger().warning("VoteStreaks.ProgressGroups has an empty group id; skipping."); + continue; + } + + groupId = groupId.trim(); + if (!progressGroupPattern.matcher(groupId).matches()) { + plugin.getLogger().warning("VoteStreaks.ProgressGroups '" + groupId + + "' is invalid (only A-Z, 0-9, _, -). Skipping."); + continue; + } + if (byId.containsKey(groupId.toLowerCase(Locale.ROOT))) { + plugin.getLogger().warning("VoteStreaks.ProgressGroups '" + groupId + + "' duplicates an existing VoteStreak id; skipping."); + continue; + } + + ConfigurationSection groupSec = progressGroups.getConfigurationSection(groupId); + if (groupSec == null) { + plugin.getLogger().warning("VoteStreaks.ProgressGroups '" + groupId + "' is not a section; skipping."); + continue; + } + + VoteStreakType type = readType("VoteStreaks.ProgressGroups '" + groupId + "'", groupSec.getString("Type")); + if (type == null) { + continue; + } + + boolean groupEnabled = groupSec.getBoolean("Enabled", true); + int votesRequired = Math.max(1, groupSec.getInt("VotesRequired", 1)); + int allowMissedAmount = Math.max(0, groupSec.getInt("AllowMissedAmount", 0)); + int allowMissedPeriod = Math.max(0, groupSec.getInt("AllowMissedPeriod", 0)); + + ConfigurationSection milestones = groupSec.getConfigurationSection("Milestones"); + if (milestones == null || milestones.getKeys(false).isEmpty()) { + plugin.getLogger() + .warning("VoteStreaks.ProgressGroups '" + groupId + "' has no Milestones; skipping."); + continue; + } + + for (String milestoneId : milestones.getKeys(false)) { + loadProgressGroupMilestone(groupId, groupSec, milestones, milestoneId, type, groupEnabled, + votesRequired, allowMissedAmount, allowMissedPeriod); + } + } + } + + private void loadProgressGroupMilestone(String groupId, ConfigurationSection groupSec, + ConfigurationSection milestones, String milestoneId, VoteStreakType type, boolean groupEnabled, + int votesRequired, int allowMissedAmount, int allowMissedPeriod) { + if (milestoneId == null || milestoneId.trim().isEmpty()) { + plugin.getLogger().warning("VoteStreaks.ProgressGroups '" + groupId + "' has an empty milestone id; skipping."); + return; + } + + milestoneId = milestoneId.trim(); + if (!idPattern.matcher(milestoneId).matches()) { + plugin.getLogger().warning("VoteStreaks.ProgressGroups '" + groupId + "' milestone '" + milestoneId + + "' is invalid (only A-Z, 0-9, _, -). Skipping."); + return; + } + + if (byId.containsKey(milestoneId.toLowerCase(Locale.ROOT))) { + plugin.getLogger().warning("VoteStreaks.ProgressGroups '" + groupId + "' milestone '" + milestoneId + + "' duplicates an existing VoteStreak id; skipping."); + return; + } + + ConfigurationSection milestoneSec = milestones.getConfigurationSection(milestoneId); + if (milestoneSec == null) { + plugin.getLogger().warning("VoteStreaks.ProgressGroups '" + groupId + "' milestone '" + milestoneId + + "' is not a section; skipping."); + return; + } + + int amount = readRequiredAmount(milestoneSec); + if (amount <= 0) { + plugin.getLogger().warning("VoteStreaks.ProgressGroups '" + groupId + "' milestone '" + milestoneId + + "' Amount must be > 0; skipping."); + return; + } + + boolean enabled = groupEnabled && milestoneSec.getBoolean("Enabled", true); + boolean recurring = milestoneSec.getBoolean("Recurring", groupSec.getBoolean("Recurring", true)); + String rewardPath = "VoteStreaks.ProgressGroups." + groupId + ".Milestones." + milestoneId + ".Rewards"; + + VoteStreakDefinition def = new VoteStreakDefinition(milestoneId, type, enabled, amount, votesRequired, + allowMissedAmount, allowMissedPeriod, recurring, groupId, rewardPath); + + plugin.getUserManager().getDataManager() + .addKey(new UserDataKeyString(getColumnName(def)).setColumnType("MEDIUMTEXT")); + + byId.put(milestoneId.toLowerCase(Locale.ROOT), def); + byProgressGroup.putIfAbsent(groupId.toLowerCase(Locale.ROOT), def); + ordered.add(def); + } + + private VoteStreakType readType(String logPrefix, String typeStr) { + VoteStreakType type = null; + try { + type = VoteStreakType.from(typeStr); + } catch (Exception e) { + plugin.getLogger().warning(logPrefix + " has invalid Type '" + typeStr + "'; skipping."); + } + if (type == null) { + plugin.getLogger().warning(logPrefix + " has invalid Type '" + typeStr + "'; skipping."); + } + return type; + } + + private int readRequiredAmount(ConfigurationSection sec) { + int amount = sec.getInt("Amount", 0); + ConfigurationSection req = sec.getConfigurationSection("Requirements"); + if (amount <= 0 && req != null) { + amount = req.getInt("Amount", 0); + } + return amount; + } } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/votestreak/VoteStreakHandlerTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/votestreak/VoteStreakHandlerTest.java index 56613e52e..12ef2cdd4 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/votestreak/VoteStreakHandlerTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/votestreak/VoteStreakHandlerTest.java @@ -177,6 +177,25 @@ private static void addStreak(ConfigurationSection voteStreaks, String id, Strin def.set("AllowMissedPeriod", allowMissedPeriod); } + private static ConfigurationSection addProgressGroup(ConfigurationSection voteStreaks, String groupId, String type, + int votesRequired, int allowMissedAmount, int allowMissedPeriod) { + ConfigurationSection group = voteStreaks.createSection("ProgressGroups").createSection(groupId); + group.set("Type", type); + group.set("VotesRequired", votesRequired); + group.set("AllowMissedAmount", allowMissedAmount); + group.set("AllowMissedPeriod", allowMissedPeriod); + group.createSection("Milestones"); + return group; + } + + private static void addProgressGroupMilestone(ConfigurationSection group, String id, int amount, boolean enabled, + boolean recurring) { + ConfigurationSection milestone = group.getConfigurationSection("Milestones").createSection(id); + milestone.set("Enabled", enabled); + milestone.set("Amount", amount); + milestone.set("Recurring", recurring); + } + /** * periodKey|streakCount|votesThisPeriod|countedThisPeriod|missWindowStartKey|missesUsed * @@ -220,12 +239,48 @@ void configLoader_loadsDefinition_andGetDefinitionWorks() { assertTrue(def.isEnabled()); assertEquals(5, def.getRequiredAmount()); assertEquals(2, def.getVotesRequired()); + assertEquals("", def.getProgressGroup()); assertEquals(def, handler.getDefinition("DailyStreak")); assertEquals(def, handler.getDefinition("dailystreak")); assertEquals(def, handler.getDefinition("DAILYSTREAK")); } + @Test + void configLoader_loadsProgressGroup_andUsesSharedColumnName() { + MemoryConfiguration root = new MemoryConfiguration(); + ConfigurationSection voteStreaks = root.createSection("VoteStreaks"); + ConfigurationSection group = addProgressGroup(voteStreaks, "continuousstreak", "DAILY", 2, 1, 7); + addProgressGroupMilestone(group, "Daily3", 3, true, false); + + loadFromRoot(root); + + VoteStreakDefinition def = handler.getDefinition("Daily3"); + assertNotNull(def); + assertEquals("continuousstreak", def.getProgressGroup()); + assertEquals(2, def.getVotesRequired()); + assertEquals(1, def.getAllowMissedAmount()); + assertEquals(7, def.getAllowMissedPeriod()); + assertFalse(def.isRecurring()); + assertEquals("VoteStreakGroup_DAILY_continuousstreak", handler.getColumnName(def)); + assertEquals("VoteStreaks.ProgressGroups.continuousstreak.Milestones.Daily3.Rewards", def.getRewardPath()); + } + + @Test + void configLoader_skipsProgressGroupThatDuplicatesFlatStreakId() { + MemoryConfiguration root = new MemoryConfiguration(); + ConfigurationSection voteStreaks = root.createSection("VoteStreaks"); + addStreak(voteStreaks, "ContinuousStreak", "DAILY", true, 3, 1, 0, 0); + ConfigurationSection group = addProgressGroup(voteStreaks, "continuousstreak", "DAILY", 1, 0, 0); + addProgressGroupMilestone(group, "SharedGroupTestA", 1, true, true); + + loadFromRoot(root); + + assertNotNull(handler.getDefinition("ContinuousStreak")); + assertNull(handler.getDefinition("SharedGroupTestA")); + assertTrue(handler.getProgressGroups().isEmpty()); + } + @Test void processVote_whenAlreadyCountedThisPeriod_doesNotIncrementStreakAgain() { MemoryConfiguration root = rootWithOneStreak("test", "DAILY", true, 9999, 2, 0, 0); @@ -274,6 +329,53 @@ void processVote_acceptsOldFiveFieldFormat_andUpgradesState() { assertEquals("2", p[5], "missesUsed preserved"); } + @Test + void processVote_sharedProgressGroupCountsOneVoteOnceForMultipleDefinitions() { + MemoryConfiguration root = new MemoryConfiguration(); + ConfigurationSection voteStreaks = root.createSection("VoteStreaks"); + ConfigurationSection group = addProgressGroup(voteStreaks, "continuousstreak", "DAILY", 1, 1, 7); + addProgressGroupMilestone(group, "Daily99", 99, true, false); + addProgressGroupMilestone(group, "Daily100", 100, true, false); + loadFromRoot(root); + + VoteStreakDefinition daily99 = handler.getDefinition("Daily99"); + VoteStreakDefinition daily100 = handler.getDefinition("Daily100"); + assertNotNull(daily99); + assertNotNull(daily100); + + Map backing = new HashMap<>(); + VotingPluginUser user = mapBackedUser(UUID.randomUUID(), "Ben", backing); + + handler.processVote(user, System.currentTimeMillis(), UUID.randomUUID()); + + String groupCol = "VoteStreakGroup_DAILY_continuousstreak"; + String[] p = parseState(backing.get(groupCol)); + assertEquals("1", p[1], "shared progress should increment once for one vote"); + assertFalse(backing.containsKey("VoteStreak_Daily99")); + assertFalse(backing.containsKey("VoteStreak_Daily100")); + assertEquals(groupCol, handler.getColumnName(daily99)); + assertEquals(groupCol, handler.getColumnName(daily100)); + } + + @Test + void processVote_sharedProgressGroupDoesNotRefireRewardedOneTimeMilestone() { + MemoryConfiguration root = new MemoryConfiguration(); + ConfigurationSection voteStreaks = root.createSection("VoteStreaks"); + ConfigurationSection group = addProgressGroup(voteStreaks, "continuousstreak", "DAILY", 1, 1, 7); + addProgressGroupMilestone(group, "Daily3", 3, true, false); + loadFromRoot(root); + + Map backing = new HashMap<>(); + VotingPluginUser user = mapBackedUser(UUID.randomUUID(), "Ben", backing); + backing.put("VoteStreakGroup_DAILY_continuousstreak", "2026-01-10|2|0|false||0|daily3"); + + handler.processVote(user, System.currentTimeMillis(), UUID.randomUUID()); + + String[] p = parseState(backing.get("VoteStreakGroup_DAILY_continuousstreak")); + assertEquals("3", p[1], "shared progress should still advance"); + assertEquals("daily3", p[6], "reward history should be preserved"); + } + @Test void shouldReward_respectsRecurringFlag() throws Exception { VoteStreakDefinition oneTime = new VoteStreakDefinition("oneTime", VoteStreakType.DAILY, true, 3, 1, 0, 0, @@ -347,6 +449,65 @@ void resetVoteStreak_byIdResetsOnlyMatchingDefinition() { assertEquals("2026-W02|2|1|true||0", backing.get(handler.getColumnName(weekly2))); } + @Test + void resetVoteStreak_byProgressGroupResetsSharedState() { + MemoryConfiguration root = new MemoryConfiguration(); + ConfigurationSection voteStreaks = root.createSection("VoteStreaks"); + ConfigurationSection group = addProgressGroup(voteStreaks, "continuousstreak", "DAILY", 1, 0, 0); + addProgressGroupMilestone(group, "SharedGroupTestA", 1, true, true); + addProgressGroupMilestone(group, "SharedGroupTestB", 1, true, true); + addStreak(voteStreaks, "UngroupedControl", "DAILY", true, 1, 1, 0, 0); + loadFromRoot(root); + + Map backing = new HashMap<>(); + VotingPluginUser user = mapBackedUser(UUID.randomUUID(), "Ben", backing); + String groupCol = "VoteStreakGroup_DAILY_continuousstreak"; + String controlCol = "VoteStreak_UngroupedControl"; + backing.put(groupCol, "2026-01-10|1|1|true||0"); + backing.put(controlCol, "2026-01-10|1|1|true||0"); + + assertTrue(handler.resetVoteStreak(user, "continuousstreak")); + + assertEquals("", backing.get(groupCol)); + assertEquals("2026-01-10|1|1|true||0", backing.get(controlCol)); + } + + @Test + void resetVoteStreak_byProgressGroupMilestoneIdDoesNotResetSharedState() { + MemoryConfiguration root = new MemoryConfiguration(); + ConfigurationSection voteStreaks = root.createSection("VoteStreaks"); + ConfigurationSection group = addProgressGroup(voteStreaks, "continuousstreak", "DAILY", 1, 0, 0); + addProgressGroupMilestone(group, "SharedGroupTestA", 1, true, true); + loadFromRoot(root); + + Map backing = new HashMap<>(); + VotingPluginUser user = mapBackedUser(UUID.randomUUID(), "Ben", backing); + String groupCol = "VoteStreakGroup_DAILY_continuousstreak"; + backing.put(groupCol, "2026-01-10|1|1|true||0"); + + assertFalse(handler.resetVoteStreak(user, "SharedGroupTestA")); + + assertEquals("2026-01-10|1|1|true||0", backing.get(groupCol)); + } + + @Test + void resetVoteStreaks_countsSharedProgressGroupOnce() { + MemoryConfiguration root = new MemoryConfiguration(); + ConfigurationSection voteStreaks = root.createSection("VoteStreaks"); + ConfigurationSection group = addProgressGroup(voteStreaks, "continuousstreak", "DAILY", 1, 0, 0); + addProgressGroupMilestone(group, "SharedGroupTestA", 1, true, true); + addProgressGroupMilestone(group, "SharedGroupTestB", 1, true, true); + addStreak(voteStreaks, "UngroupedControl", "DAILY", true, 1, 1, 0, 0); + loadFromRoot(root); + + Map backing = new HashMap<>(); + VotingPluginUser user = mapBackedUser(UUID.randomUUID(), "Ben", backing); + backing.put("VoteStreakGroup_DAILY_continuousstreak", "2026-01-10|1|1|true||0"); + backing.put("VoteStreak_UngroupedControl", "2026-01-10|1|1|true||0"); + + assertEquals(2, handler.resetVoteStreaks(user)); + } + @Test void resetVoteStreak_unknownIdReturnsFalse() { loadFromRoot(rootWithThreeStreaks());