Compare commits

...
33 Commits
Author SHA1 Message Date
BenCodez 39f4717f36 Update version number to 7.1.1 2026-07-22 19:45:03 -06:00
Ben 46f86f8756 Merge pull request #1532 from BenCodez/agent/fix-proxy-queued-vote-delay
Fix queued proxy votes being rejected by vote delay
2026-07-22 16:17:40 -06:00
Ben f99782117a Test proxy queued vote delay check 2026-07-22 16:13:14 -06:00
Ben ae65946561 Add proxy vote delay helper 2026-07-22 16:13:12 -06:00
Ben e595fd6d40 Fix proxy queued vote delay check 2026-07-22 16:13:11 -06:00
BenCodez 9b585e3d80 Refactor forceCache logic in VotingPluginProxy for clarity
Updated the forceCache condition to improve readability and ensure
votes are cached correctly based on user online status and server
validation. This change enhances the handling of vote caching
when players are offline or when server information is unavailable.
2026-07-19 15:41:04 -06:00
BenCodez b4b371f19b Remove voteUUID from VoteTotalsSnapshot output and update debug logs
- Eliminated voteUUID from the string output in VoteTotalsSnapshot.
- Updated debug logging in VotingPluginProxy to include voteId and
player information.
- Removed related assertions in VoteTotalsSnapshotStorageTest.
2026-07-19 14:38:40 -06:00
BenCodez e3be6f7b83 Deprecate old VoteTotalsSnapshot constructor
- Mark VoteTotalsSnapshot constructor as deprecated
- Remove unused voteId parameter from various method calls
- Improve code readability by formatting method parameters
2026-07-19 14:25:27 -06:00
Ben dd1ad376e8 Merge pull request #1531 from BenCodez/agent/atomic-proxy-cache-dedup
Make proxy cache vote deduplication atomic
2026-07-19 13:48:34 -06:00
Ben 25aff53be4 Make proxy cache vote deduplication atomic 2026-07-19 13:45:39 -06:00
Ben 37c184285f Merge pull request #1530 from BenCodez/agent/consolidate-proxy-vote-dedup
Complete proxy vote deduplication and preserve queued vote IDs
2026-07-19 13:42:24 -06:00
Ben 4ee7af2c1f Preserve vote IDs through proxy cache and time queue 2026-07-19 13:38:52 -06:00
Ben 7f4a576c27 Merge pull request #1527 from BenCodez/agent/wire-vote-idempotency
Prevent duplicate proxy wire vote rewards
2026-07-19 13:21:29 -06:00
Ben 9fa27fc450 Test proxy wire vote ID round trips 2026-07-19 13:17:57 -06:00
Ben 1ade11ecaf Import UUID for proxy wire vote IDs 2026-07-19 13:11:52 -06:00
Ben 41450d5739 Preserve BungeeHandler line endings 2026-07-19 13:07:32 -06:00
Ben 4c24ca08ae Reject duplicate wire vote deliveries 2026-07-19 13:07:03 -06:00
Ben 5fc36a0981 Propagate vote IDs through immediate and cached delivery 2026-07-19 13:06:20 -06:00
Ben be44c75cca Add explicit vote IDs to proxy wire messages 2026-07-19 13:06:19 -06:00
BenCodez aab8791821 Update BungeeHandler to adjust backend total management logic
Changed the variable name for clarity and updated the method call to
reflect the new logic for managing totals on the backend. This
ensures correct handling of broadcast settings during user voting
processes.
2026-07-19 12:50:59 -06:00
BenCodez b08bd76c9c Update null check for service in BungeeHandler and fix config usage
- Changed condition to check for null before checking if service is
empty.
- Adjusted calls in VotingPluginProxy to ensure correct config usage for
Bungee management.
2026-07-19 12:47:26 -06:00
BenCodez 8773eb211f Update condition check in BungeeHandler for service management
Modified the condition to check if the service is not empty instead of
managing totals, improving clarity and functionality.
2026-07-19 12:45:38 -06:00
BenCodez 32f596206e Update service site message handling and remove deprecated annotation
- Prevent display of no service site message if disabled in config
- Removed deprecated annotation from ServerData class
2026-07-19 12:30:29 -06:00
BenCodez e67895531a Rename ServiceSites to GottenServiceSites in ServerData class
Updated method return values and data storage keys to reflect the new
naming convention. Marked previous method as deprecated for future
reference.
2026-07-17 19:34:40 -06:00
BenCodez 1feef348ab Add placeholder for NameMC like reward status in PlaceHolders class
Introduces a new placeholder "CanLike_NameMC" that returns "Complete"
if the user has claimed the NameMC like reward, otherwise returns
"Incomplete".
2026-07-11 11:40:31 -06:00
BenCodez 7fcbe76922 Fix typo in VoteSiteJsonPresetManager for wait delay key
Updated the key from "WaitUnitVoteDelay" to "WaitUntilVoteDelay" for
consistency.
2026-07-06 20:00:09 -06:00
BenCodez dcab774d26 Add admin vote shop category management and item handling from inventory
- Implemented GUI for adding, editing, and removing vote shop
categories.
- Added functionality to create shop items from held items and manage
their display settings.
- Enhanced item management with category-specific handling for better
organization.
- Updated configuration paths for categories and items in the shop file.
2026-06-27 21:13:51 -06:00
BenCodez f405794287 Add success message for shop item creation in AdminVoteVoteShop
- Implemented player feedback upon successful shop item creation
2026-06-20 10:35:36 -06:00
BenCodez f47129143e Update spigot version in pom.xml to 26.2-R0.1-SNAPSHOT 2026-06-16 19:14:25 -06:00
BenCodez a9c773d949 Refactor VotingPluginMain to streamline loading order 2026-06-14 13:51:34 -06:00
BenCodez 67e2fdde51 Refactor BroadcastHandlerTest for improved clarity and functionality
- Removed unused imports and streamlined test setup.
- Updated variable names for better readability.
- Ensured console output verification instead of mocking external
utilities.
2026-06-09 16:22:51 -06:00
BenCodez 28228cf68f Refactor BroadcastHandler for improved readability and functionality
- Updated method signatures and variable names for clarity.
- Enhanced Javadoc comments for better documentation.
- Improved handling of player UUIDs and broadcast settings.
- Streamlined message broadcasting logic with placeholder parsing.
2026-06-09 16:15:06 -06:00
BenCodez 5785908401 Bump version to 7.1.1-SNAPSHOT 2026-06-07 20:53:25 -06:00
29 changed files with 2097 additions and 387 deletions

No files matched your search

+3 -3
View File
@@ -4,7 +4,7 @@
<modelVersion>4.0.0</modelVersion>
<groupId>com.bencodez</groupId>
<artifactId>votingplugin</artifactId>
<version>7.1</version>
<version>7.1.1</version>
<packaging>jar</packaging>
<name>VotingPlugin</name>
<properties>
@@ -237,7 +237,7 @@
<dependency>
<groupId>org.spigotmc</groupId>
<artifactId>spigot-api</artifactId>
<version>26.1.2-R0.1-SNAPSHOT</version>
<version>26.2-R0.1-SNAPSHOT</version>
<scope>provided</scope>
</dependency>
<dependency>
@@ -285,7 +285,7 @@
<dependency>
<groupId>com.bencodez</groupId>
<artifactId>advancedcore</artifactId>
<version>3.8.1</version>
<version>3.8.2-SNAPSHOT</version>
<scope>compile</scope>
</dependency>
<dependency>
@@ -8,6 +8,7 @@ import java.time.ZoneOffset;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
@@ -51,6 +52,11 @@ import lombok.Getter;
* Handler for Bungee/proxy server integration.
*/
public class BungeeHandler implements Listener {
private static final long PROCESSED_VOTE_TTL_MILLIS = TimeUnit.MINUTES.toMillis(30);
@Getter
private final ConcurrentHashMap<UUID, Long> processedWireVotes = new ConcurrentHashMap<>();
@Getter
private ClientHandler clientHandler;
@@ -575,11 +581,18 @@ public class BungeeHandler implements Listener {
plugin.debug("wire vote received from " + player + "/" + uuidStr + " on " + service);
VoteTotalsSnapshot text = VoteTotalsSnapshot.parseStorage(v.totals == null ? "" : v.totals);
@SuppressWarnings("deprecation")
UUID voteId = v.voteId != null ? v.voteId : text.getVoteUUID();
if (!reserveWireVote(voteId)) {
plugin.debug("Ignoring duplicate wire vote " + voteId + " for " + player + " on " + service);
return;
}
VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(UUID.fromString(uuidStr),
player);
VoteTotalsSnapshot text = VoteTotalsSnapshot.parseStorage(v.totals == null ? "" : v.totals);
bungeeVotePartyCurrent = text.getVotePartyCurrent();
bungeeVotePartyRequired = text.getVotePartyRequired();
plugin.getServerData().setBungeeVotePartyCurrent(bungeeVotePartyCurrent);
@@ -587,19 +600,15 @@ public class BungeeHandler implements Listener {
user.cache();
boolean bungeeBroadcast = v.broadcast;
boolean setTotalsOnBackend = !v.manageTotals;
// Wire has no "setTotals" flag; treat as always true (so doNotSetTotals =
// false)
boolean doNotSetTotals = false;
user.bungeeVotePluginMessaging(service, v.time, text, doNotSetTotals, v.wasOnline, bungeeBroadcast, v.num);
user.bungeeVotePluginMessaging(service, v.time, text, setTotalsOnBackend, v.wasOnline, v.broadcast, v.num);
if (plugin.getBungeeSettings().isPerServerPoints()) {
user.addPoints(plugin.getConfigFile().getPointsOnVote());
}
if (v.manageTotals) {
if (service != null && !service.isEmpty()) {
plugin.getServerData().addServiceSite(service);
}
@@ -607,6 +616,50 @@ public class BungeeHandler implements Listener {
int _ignored = v.numberOfVotes;
}
/**
* Reserves a wire vote for processing.
*
* @param voteId unique vote identifier
* @return true if the vote has not been processed recently
*/
private boolean reserveWireVote(UUID voteId) {
if (voteId == null) {
return true;
}
long now = System.currentTimeMillis();
long expiresAt = now + PROCESSED_VOTE_TTL_MILLIS;
while (true) {
Long currentExpiry = processedWireVotes.get(voteId);
if (currentExpiry == null) {
if (processedWireVotes.putIfAbsent(voteId, expiresAt) == null) {
cleanupProcessedWireVotes(now);
return true;
}
continue;
}
if (currentExpiry > now) {
return false;
}
if (processedWireVotes.replace(voteId, currentExpiry, expiresAt)) {
cleanupProcessedWireVotes(now);
return true;
}
}
}
/**
* Removes expired wire vote identifiers.
*
* @param now current timestamp
*/
private void cleanupProcessedWireVotes(long now) {
processedWireVotes.entrySet().removeIf(entry -> entry.getValue() <= now);
}
/**
* Loads the global MySQL handler for cross-server data synchronization.
*/
@@ -877,34 +877,6 @@ public class VotingPluginMain extends AdvancedCorePlugin {
}
});
}
if (plugin.getSpecialRewardsConfig().getData().getConfigurationSection("VoteStreaks") != null) {
for (String streak : plugin.getSpecialRewardsConfig().getData().getConfigurationSection("VoteStreaks")
.getKeys(false)) {
plugin.addDirectlyDefinedRewards(new DirectlyDefinedReward("VoteStreaks." + streak + ".Rewards") {
@Override
public void setData(String path, Object value) {
plugin.getSpecialRewardsConfig().getData().set(path, value);
}
@Override
public void createSection(String key) {
plugin.getSpecialRewardsConfig().getData().createSection(key);
}
@Override
public ConfigurationSection getFileData() {
return getSpecialRewardsConfig().getData();
}
@Override
public void save() {
plugin.getSpecialRewardsConfig().saveData();
}
});
}
}
ConfigurationSection voteMilestonesSection = plugin.getSpecialRewardsConfig().getData()
.getConfigurationSection("VoteMilestones");
@@ -1293,6 +1265,11 @@ public class VotingPluginMain extends AdvancedCorePlugin {
getLogger().info("Loading PlaceholderAPI expansion");
}
}
voteMilestonesManager = new VoteMilestonesManager(this);
voteStreakHandler = new VoteStreakHandler(this);
voteStreakHandler.reload();
registerCommands();
checkVotifier();
@@ -1337,7 +1314,7 @@ public class VotingPluginMain extends AdvancedCorePlugin {
topVoterHandler.register();
voteMilestonesManager = new VoteMilestonesManager(this);
plugin.getBukkitScheduler().runTaskAsynchronously(plugin, new Runnable() {
@@ -1379,9 +1356,6 @@ public class VotingPluginMain extends AdvancedCorePlugin {
// load vote logging if enabled
loadVoteLoggingMySQL();
voteStreakHandler = new VoteStreakHandler(this);
voteStreakHandler.reload();
webhooks = new VotingPluginWebhooks(this);
webhooks.reload(getConfig().getConfigurationSection("Webhooks"));
@@ -1545,7 +1519,7 @@ public class VotingPluginMain extends AdvancedCorePlugin {
contains = true;
}
}
if (!contains) {
if (!contains && !getConfigFile().isDisableNoServiceSiteMessage()) {
issues = false;
plugin.getLogger().warning("No vote has been recieved from " + site.getServiceSite()
+ ", may be an invalid service site. Please read: https://github.com/BenCodez/VotingPlugin/wiki/Votifier-Troubleshooting");
@@ -13,14 +13,16 @@ import java.util.Set;
import java.util.StringJoiner;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;
import org.bukkit.Bukkit;
import org.bukkit.OfflinePlayer;
import org.bukkit.entity.Player;
import org.bukkit.scheduler.BukkitTask;
import com.bencodez.advancedcore.api.misc.MiscUtils;
import com.bencodez.advancedcore.api.messages.PlaceholderUtils;
import com.bencodez.simpleapi.messages.MessageAPI;
import com.bencodez.simpleapi.player.PlayerUtils;
import com.bencodez.simpleapi.time.ParsedDuration;
import com.bencodez.votingplugin.VotingPluginMain;
import com.bencodez.votingplugin.user.VotingPluginUser;
@@ -28,42 +30,38 @@ import com.bencodez.votingplugin.user.VotingPluginUser;
/**
* Core broadcast logic.
*
* Decides WHEN to broadcast and builds lines/messages. Uses your existing
* "eligible online players" behavior (disable broadcast respected).
* Decides when to broadcast and builds lines and messages. Uses the existing
* eligible online players behavior and respects disabled broadcasts.
*
* NOTE: Backend servers only.
* Backend servers only.
*/
public final class BroadcastHandler {
private final VotingPluginMain plugin;
/**
* Only used for day-boundary logic (FIRST_VOTE_OF_DAY).
* Only used for day-boundary logic for first vote of day broadcasts.
*/
private final ZoneId zoneId;
private volatile BroadcastSettings settings;
// Per-player cooldown tracking
private final ConcurrentHashMap<UUID, Instant> lastBroadcastAt = new ConcurrentHashMap<UUID, Instant>();
// Batch window tracking
private final ConcurrentHashMap<UUID, LinkedHashSet<String>> pendingSites = new ConcurrentHashMap<UUID, LinkedHashSet<String>>();
private final ConcurrentHashMap<UUID, BukkitTask> pendingFlush = new ConcurrentHashMap<UUID, BukkitTask>();
// First vote of day tracking
private final ConcurrentHashMap<UUID, LocalDate> firstVoteDay = new ConcurrentHashMap<UUID, LocalDate>();
// Interval summary tracking (uuid -> set of sites during interval)
private final ConcurrentHashMap<UUID, LinkedHashSet<String>> intervalSites = new ConcurrentHashMap<UUID, LinkedHashSet<String>>();
private volatile BukkitTask intervalTask;
/**
* Constructs a new BroadcastHandler.
* Constructs a new broadcast handler.
*
* @param plugin the main plugin instance
* @param plugin the main plugin instance
* @param settings the broadcast settings
* @param zoneId the time zone ID, or null to use system default
* @param zoneId the time zone ID, or null to use the system default
*/
public BroadcastHandler(VotingPluginMain plugin, BroadcastSettings settings, ZoneId zoneId) {
this.plugin = plugin;
@@ -74,7 +72,8 @@ public final class BroadcastHandler {
}
/**
* Updates the broadcast settings and reschedules interval task if needed.
* Updates the broadcast settings and reschedules the interval task when
* required.
*
* @param settings the new broadcast settings
*/
@@ -84,118 +83,131 @@ public final class BroadcastHandler {
}
/**
* Call whenever a vote is received.
* Handles a received vote.
*
* @param uuid player's uuid
* @param playerName player name (optional; if null/empty, resolved from Bukkit)
* @param siteName vote site name (display name)
* @param wasOnline whether the player was online when the vote was received
* @param uuid the voted player's UUID
* @param playerName the player name, or null to resolve it from Bukkit
* @param siteName the vote site display name
* @param wasOnline whether the voted player was online when the vote was received
*/
public void broadcastVote(UUID uuid, String playerName, String siteName, boolean wasOnline) {
BroadcastSettings s = settings;
if (s == null || s.isDisabled()) {
BroadcastSettings currentSettings = settings;
if (currentSettings == null || currentSettings.isDisabled()) {
return;
}
// Always collect for interval summaries (even if Type isn't interval right
// now).
recordInterval(uuid, siteName);
String name = playerName == null || playerName.isEmpty() ? resolveName(uuid) : playerName;
VoteBroadcastType type = currentSettings.getType();
VoteBroadcastType type = s.getType();
// New: types that only broadcast if the voting player is online
if (type == VoteBroadcastType.EVERY_VOTE_ONLINE_ONLY) {
if (!wasOnline) {
return;
}
broadcastNow(name, single(siteName), "vote_online_only", null);
broadcastNow(uuid, name, single(siteName), "vote_online_only", null);
return;
}
if (type == VoteBroadcastType.EVERY_VOTE) {
broadcastNow(name, single(siteName), "vote", null);
broadcastNow(uuid, name, single(siteName), "vote", null);
} else if (type == VoteBroadcastType.COOLDOWN_PER_PLAYER) {
if (checkAndMarkCooldown(uuid, s.getDuration())) {
broadcastNow(name, single(siteName), "cooldown", null);
if (checkAndMarkCooldown(uuid, currentSettings.getDuration())) {
broadcastNow(uuid, name, single(siteName), "cooldown", null);
}
} else if (type == VoteBroadcastType.BATCH_WINDOW_PER_PLAYER) {
bufferBatch(uuid, name, siteName, s.getDuration());
bufferBatch(uuid, name, siteName, currentSettings.getDuration());
} else if (type == VoteBroadcastType.FIRST_VOTE_OF_DAY) {
if (markFirstVoteOfDay(uuid)) {
broadcastNow(name, single(siteName), "first_day", null);
broadcastNow(uuid, name, single(siteName), "first_day", null);
}
} else if (type == VoteBroadcastType.INTERVAL_SUMMARY_GLOBAL) {
// handled by scheduled task
// Handled by the scheduled interval task.
}
}
/*
* ======================= Broadcast helpers =======================
/**
* Renders and broadcasts a message.
*
* All PlaceholderAPI placeholders are evaluated using the voted player before
* the message is sent to recipients.
*
* @param votedPlayerUuid the voted player's UUID, or null when no single player applies
* @param playerName the rendered player name
* @param sites the vote sites
* @param reason the broadcast reason
* @param extraContext additional format context, or null
*/
private void broadcastNow(String playerName, List<String> sites, String reason, Map<String, String> extraContext) {
BroadcastSettings s = settings;
if (s == null || s.isDisabled()) {
private void broadcastNow(UUID votedPlayerUuid, String playerName, List<String> sites, String reason,
Map<String, String> extraContext) {
BroadcastSettings currentSettings = settings;
if (currentSettings == null || currentSettings.isDisabled()) {
return;
}
List<String> cleaned = applyMaxSites(sites, s);
List<String> cleaned = applyMaxSites(sites, currentSettings);
cleaned.sort(new Comparator<String>() {
@Override
public int compare(String a, String b) {
String aa = a == null ? "" : a.toLowerCase(Locale.ROOT);
String bb = b == null ? "" : b.toLowerCase(Locale.ROOT);
return aa.compareTo(bb);
public int compare(String first, String second) {
String firstValue = first == null ? "" : first.toLowerCase(Locale.ROOT);
String secondValue = second == null ? "" : second.toLowerCase(Locale.ROOT);
return firstValue.compareTo(secondValue);
}
});
List<String> lines;
if (extraContext == null) {
lines = s.getFormat().render(playerName, cleaned, reason);
lines = currentSettings.getFormat().render(playerName, cleaned, reason);
} else {
lines = s.getFormat().render(playerName, cleaned, reason, extraContext);
lines = currentSettings.getFormat().render(playerName, cleaned, reason, extraContext);
}
OfflinePlayer votedPlayer = votedPlayerUuid == null ? null : Bukkit.getOfflinePlayer(votedPlayerUuid);
for (String line : lines) {
broadcastToEligiblePlayers(line);
String parsedLine = PlaceholderUtils.replacePlaceHolders(votedPlayer, line);
broadcastToEligiblePlayers(parsedLine);
}
}
/**
* Your reliable broadcast style: - only sends to online players - respects
* per-player disable broadcast toggle - runs PlaceholderAPI style per-player
* placeholders (if your PlaceholderUtils does) - supports %newline% / %NewLine%
* splitting
* Broadcasts a message that has already had PlaceholderAPI placeholders parsed.
*
* Placeholder parsing is intentionally not performed for each recipient because
* all placeholders must be based on the voted player. JSON-style click and hover
* formatting is still parsed for each recipient.
*
* @param broadcastMessage the parsed message
*/
private void broadcastToEligiblePlayers(String broadcastMsg) {
if (broadcastMsg == null || broadcastMsg.isEmpty()) {
private void broadcastToEligiblePlayers(String broadcastMessage) {
if (broadcastMessage == null || broadcastMessage.isEmpty()) {
return;
}
ArrayList<Player> players = new ArrayList<Player>();
for (Player p : Bukkit.getOnlinePlayers()) {
VotingPluginUser u = plugin.getVotingPluginUserManager().getVotingPluginUser(p);
if (u != null && !u.getDisableBroadcast()) {
players.add(p);
for (Player player : Bukkit.getOnlinePlayers()) {
VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(player);
if (user == null || user.getDisableBroadcast()) {
continue;
}
for (String firstSplit : broadcastMessage.split(Pattern.quote("%newline%"))) {
for (String message : firstSplit.split(Pattern.quote("%NewLine%"))) {
String colorizedMessage = MessageAPI.colorize(message);
PlayerUtils.getServerHandle().sendMessage(player, PlaceholderUtils.parseJson(colorizedMessage));
}
}
}
// If you prefer your manual loop, keep it here (not in main)
MiscUtils.getInstance().broadcast(broadcastMsg, players);
Bukkit.getServer().getConsoleSender().sendMessage(MessageAPI.colorize(broadcastMsg));
Bukkit.getServer().getConsoleSender().sendMessage(MessageAPI.colorize(broadcastMessage));
}
/*
* ======================= Cooldown logic (fixed millis) =======================
/**
* Checks and updates a player's broadcast cooldown.
*
* @param uuid the player's UUID
* @param cooldown the cooldown duration
* @return true when the broadcast is allowed
*/
private boolean checkAndMarkCooldown(UUID uuid, ParsedDuration cooldown) {
if (cooldown == null || cooldown.isEmpty()) {
return true;
@@ -218,38 +230,50 @@ public final class BroadcastHandler {
return false;
}
/*
* ======================= Batch window logic (fixed millis)
* =======================
/**
* Adds a vote site to a player's pending batch.
*
* @param uuid the player's UUID
* @param playerName the player's name
* @param siteName the vote site
* @param window the batch window
*/
private void bufferBatch(UUID uuid, String playerName, String siteName, ParsedDuration window) {
if (siteName != null && !siteName.isEmpty()) {
LinkedHashSet<String> set = pendingSites.get(uuid);
if (set == null) {
LinkedHashSet<String> sites = pendingSites.get(uuid);
if (sites == null) {
LinkedHashSet<String> created = new LinkedHashSet<String>();
set = pendingSites.putIfAbsent(uuid, created);
if (set == null) {
set = created;
sites = pendingSites.putIfAbsent(uuid, created);
if (sites == null) {
sites = created;
}
}
synchronized (set) {
set.add(siteName);
synchronized (sites) {
sites.add(siteName);
}
}
if (!pendingFlush.containsKey(uuid)) {
BukkitTask task = scheduleBatchFlush(uuid, playerName, window);
BukkitTask prev = pendingFlush.putIfAbsent(uuid, task);
if (prev != null) {
BukkitTask previous = pendingFlush.putIfAbsent(uuid, task);
if (previous != null) {
task.cancel();
}
}
}
/**
* Schedules a pending batch flush.
*
* @param uuid the player's UUID
* @param playerName the player's name
* @param window the batch window
* @return the scheduled Bukkit task
*/
private BukkitTask scheduleBatchFlush(final UUID uuid, final String playerName, ParsedDuration window) {
long delayMs = window == null ? 1L : window.delayMillisFromNow();
long ticks = Math.max(1L, delayMs / 50L);
long delayMillis = window == null ? 1L : window.delayMillisFromNow();
long ticks = Math.max(1L, delayMillis / 50L);
return Bukkit.getScheduler().runTaskLater(plugin, new Runnable() {
@Override
@@ -263,23 +287,32 @@ public final class BroadcastHandler {
}, ticks);
}
/**
* Flushes a player's pending vote-site batch.
*
* @param uuid the player's UUID
* @param playerName the player's name
*/
private void flushBatch(UUID uuid, String playerName) {
Set<String> set = pendingSites.remove(uuid);
if (set == null || set.isEmpty()) {
Set<String> sites = pendingSites.remove(uuid);
if (sites == null || sites.isEmpty()) {
return;
}
List<String> list;
synchronized (set) {
list = new ArrayList<String>(set);
List<String> siteList;
synchronized (sites) {
siteList = new ArrayList<String>(sites);
}
broadcastNow(playerName, list, "batch", null);
broadcastNow(uuid, playerName, siteList, "batch", null);
}
/*
* ======================= First vote of day (calendar) =======================
/**
* Marks and checks a player's first vote of the current day.
*
* @param uuid the player's UUID
* @return true when this is the first vote of the day
*/
private boolean markFirstVoteOfDay(UUID uuid) {
LocalDate today = LocalDate.now(zoneId);
LocalDate last = firstVoteDay.putIfAbsent(uuid, today);
@@ -287,30 +320,34 @@ public final class BroadcastHandler {
if (last == null) {
return true;
}
if (!last.equals(today)) {
firstVoteDay.put(uuid, today);
return true;
}
return false;
}
/*
* ======================= Interval summary =======================
/**
* Reschedules the global interval summary task when required.
*/
private void rescheduleIntervalIfNeeded() {
if (intervalTask != null) {
intervalTask.cancel();
intervalTask = null;
}
BroadcastSettings s = settings;
if (s == null || s.getType() != VoteBroadcastType.INTERVAL_SUMMARY_GLOBAL) {
BroadcastSettings currentSettings = settings;
if (currentSettings == null
|| currentSettings.getType() != VoteBroadcastType.INTERVAL_SUMMARY_GLOBAL) {
return;
}
long delayMs = s.getDuration() == null ? 1L : s.getDuration().delayMillisFromNow();
long ticks = Math.max(1L, delayMs / 50L);
long delayMillis = currentSettings.getDuration() == null
? 1L
: currentSettings.getDuration().delayMillisFromNow();
long ticks = Math.max(1L, delayMillis / 50L);
intervalTask = Bukkit.getScheduler().runTaskTimer(plugin, new Runnable() {
@Override
@@ -320,36 +357,46 @@ public final class BroadcastHandler {
}, ticks, ticks);
}
/**
* Records a vote site for the global interval summary.
*
* @param uuid the player's UUID
* @param siteName the vote site
*/
private void recordInterval(UUID uuid, String siteName) {
if (siteName == null || siteName.isEmpty()) {
return;
}
LinkedHashSet<String> set = intervalSites.get(uuid);
if (set == null) {
LinkedHashSet<String> sites = intervalSites.get(uuid);
if (sites == null) {
LinkedHashSet<String> created = new LinkedHashSet<String>();
set = intervalSites.putIfAbsent(uuid, created);
if (set == null) {
set = created;
sites = intervalSites.putIfAbsent(uuid, created);
if (sites == null) {
sites = created;
}
}
synchronized (set) {
set.add(siteName);
synchronized (sites) {
sites.add(siteName);
}
}
/**
* Interval summary supports extra context placeholders: - %players% /
* %numberofplayers% - %sites% / %numberofsites%
* Broadcasts the global interval summary.
*
* The interval summary has no single voted player, so PlaceholderAPI
* placeholders are left unchanged.
*/
private void broadcastIntervalSummary() {
BroadcastSettings s = settings;
if (s == null || s.isDisabled() || s.getType() != VoteBroadcastType.INTERVAL_SUMMARY_GLOBAL) {
BroadcastSettings currentSettings = settings;
if (currentSettings == null || currentSettings.isDisabled()
|| currentSettings.getType() != VoteBroadcastType.INTERVAL_SUMMARY_GLOBAL) {
return;
}
ConcurrentHashMap<UUID, LinkedHashSet<String>> snapshot = new ConcurrentHashMap<UUID, LinkedHashSet<String>>(
intervalSites);
ConcurrentHashMap<UUID, LinkedHashSet<String>> snapshot =
new ConcurrentHashMap<UUID, LinkedHashSet<String>>(intervalSites);
intervalSites.clear();
if (snapshot.isEmpty()) {
@@ -395,49 +442,72 @@ public final class BroadcastHandler {
context.put("sites", sitesCsv);
context.put("numberofsites", String.valueOf(uniqueSites.size()));
broadcastNow("Server", entries, "interval", context);
broadcastNow(null, "Server", entries, "interval", context);
}
/*
* ======================= Utilities =======================
/**
* Creates a single-value site list.
*
* @param site the site name
* @return the site list
*/
private List<String> single(String site) {
List<String> list = new ArrayList<String>();
List<String> sites = new ArrayList<String>();
if (site != null && !site.isEmpty()) {
list.add(site);
sites.add(site);
}
return list;
return sites;
}
private List<String> applyMaxSites(List<String> sites, BroadcastSettings s) {
/**
* Applies the configured maximum number of listed sites.
*
* @param sites the original site list
* @param currentSettings the current broadcast settings
* @return the limited site list
*/
private List<String> applyMaxSites(List<String> sites, BroadcastSettings currentSettings) {
if (sites == null) {
return new ArrayList<String>();
}
int max = s.getMaxSitesListed();
if (max <= 0 || sites.size() <= max) {
int maximum = currentSettings.getMaxSitesListed();
if (maximum <= 0 || sites.size() <= maximum) {
return new ArrayList<String>(sites);
}
return new ArrayList<String>(sites.subList(0, max));
return new ArrayList<String>(sites.subList(0, maximum));
}
/**
* Resolves a player's name from Bukkit.
*
* @param uuid the player's UUID
* @return the resolved player name
*/
private String resolveName(UUID uuid) {
try {
OfflinePlayer op = Bukkit.getOfflinePlayer(uuid);
String name = op == null ? null : op.getName();
OfflinePlayer offlinePlayer = Bukkit.getOfflinePlayer(uuid);
String name = offlinePlayer == null ? null : offlinePlayer.getName();
return name == null || name.isEmpty() ? "Player" : name;
} catch (Exception e) {
} catch (Exception exception) {
return "Player";
}
}
private String join(List<String> list) {
/**
* Joins non-empty strings with a comma and space.
*
* @param values the values to join
* @return the joined string
*/
private String join(List<String> values) {
StringJoiner joiner = new StringJoiner(", ");
for (String s : list) {
if (s != null && !s.isEmpty()) {
joiner.add(s);
for (String value : values) {
if (value != null && !value.isEmpty()) {
joiner.add(value);
}
}
return joiner.toString();
}
}
}
@@ -1,10 +1,13 @@
package com.bencodez.votingplugin.commands.gui.admin.voteshop;
import java.util.ArrayList;
import java.util.Map;
import java.util.Map.Entry;
import org.bukkit.Material;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import com.bencodez.advancedcore.api.gui.GUIHandler;
import com.bencodez.advancedcore.api.gui.GUIMethod;
@@ -12,15 +15,21 @@ import com.bencodez.advancedcore.api.inventory.BInventory;
import com.bencodez.advancedcore.api.inventory.BInventory.ClickEvent;
import com.bencodez.advancedcore.api.inventory.BInventoryButton;
import com.bencodez.advancedcore.api.item.ItemBuilder;
import com.bencodez.simpleapi.messages.MessageAPI;
import com.bencodez.simpleapi.valuerequest.StringListener;
import com.bencodez.simpleapi.valuerequest.ValueRequest;
import com.bencodez.votingplugin.VotingPluginMain;
import lombok.Getter;
import lombok.Setter;
/**
* Admin vote shop GUI handler.
*/
public class AdminVoteVoteShop extends GUIHandler {
@Getter
@Setter
private VotingPluginMain plugin;
/**
@@ -75,12 +84,47 @@ public class AdminVoteVoteShop extends GUIHandler {
@Override
public void onInput(Player player, String value) {
plugin.getShopFile().createShop(value);
player.sendMessage(MessageAPI.colorize("&aShop item created successfully: " + value));
plugin.reload();
}
});
}
});
inv.addButton(new BInventoryButton(new ItemBuilder(Material.HOPPER).setName("&aAdd voteshop item from hand")
.addLoreLine("&7Hold an item in your main hand")
.addLoreLine("&7then click this button.")) {
@Override
public void onClick(ClickEvent clickEvent) {
ItemStack item = getHeldItem(clickEvent);
if (isEmpty(item)) {
clickEvent.getPlayer().sendMessage(MessageAPI.colorize("&cHold an item in your main hand first."));
new AdminVoteVoteShop(plugin, clickEvent.getPlayer()).open();
return;
}
new ValueRequest(plugin, plugin.getDialogService(), null).requestString(clickEvent.getPlayer(), "", null, true,
"Enter item name", new StringListener() {
@Override
public void onInput(Player player, String value) {
createShopFromItem(value, item);
player.sendMessage(MessageAPI.colorize("&aShop item created successfully: " + value));
plugin.reload();
}
});
}
});
inv.addButton(new BInventoryButton(new ItemBuilder(Material.CHEST).setName("&bEdit categories")) {
@Override
public void onClick(ClickEvent clickEvent) {
new AdminVoteVoteShopCategories(plugin, clickEvent.getPlayer()).open(GUIMethod.CHEST);
}
});
inv.addButton(new BInventoryButton(new ItemBuilder(Material.REDSTONE_BLOCK).setName("&cRemove item")) {
@Override
@@ -96,4 +140,47 @@ public class AdminVoteVoteShop extends GUIHandler {
open(GUIMethod.CHEST);
}
/**
* Creates a new shop item and copies the held item to display and rewards.
*
* @param identifier the shop identifier
* @param itemStack the item stack to copy
*/
private void createShopFromItem(String identifier, ItemStack itemStack) {
String basePath = "Shop." + identifier;
plugin.getShopFile().getData().set(basePath, null);
plugin.getShopFile().getData().set(basePath + ".Identifier_Name", identifier);
plugin.getShopFile().getData().set(basePath + ".Cost", 1);
plugin.getShopFile().getData().set(basePath + ".Permission", "");
plugin.getShopFile().getData().set(basePath + ".CloseGUI", true);
plugin.getShopFile().getData().set(basePath + ".RequireConfirmation", false);
ItemBuilder item = new ItemBuilder(itemStack.clone());
Map<String, Object> map = item.getConfiguration(true);
for (Entry<String, Object> entry : map.entrySet()) {
plugin.getShopFile().getData().set(basePath + ".DisplayItem.ItemStack." + entry.getKey(), entry.getValue());
plugin.getShopFile().getData().set(basePath + ".Rewards.Items.Item1.ItemStack." + entry.getKey(), entry.getValue());
}
plugin.getShopFile().saveData();
}
/**
* Gets the item the player is holding in their main hand.
*
* @param clickEvent the click event
* @return the held item
*/
private ItemStack getHeldItem(ClickEvent clickEvent) {
return clickEvent.getPlayer().getInventory().getItemInMainHand().clone();
}
/**
* Checks if an item stack is empty.
*
* @param itemStack the item stack
* @return true if empty
*/
private boolean isEmpty(ItemStack itemStack) {
return itemStack == null || itemStack.getType() == Material.AIR;
}
}
@@ -0,0 +1,229 @@
package com.bencodez.votingplugin.commands.gui.admin.voteshop;
import java.util.ArrayList;
import java.util.Map;
import java.util.Map.Entry;
import org.bukkit.Material;
import org.bukkit.command.CommandSender;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import com.bencodez.advancedcore.api.gui.GUIHandler;
import com.bencodez.advancedcore.api.gui.GUIMethod;
import com.bencodez.advancedcore.api.inventory.BInventory;
import com.bencodez.advancedcore.api.inventory.BInventory.ClickEvent;
import com.bencodez.advancedcore.api.inventory.BInventoryButton;
import com.bencodez.advancedcore.api.item.ItemBuilder;
import com.bencodez.simpleapi.messages.MessageAPI;
import com.bencodez.simpleapi.valuerequest.StringListener;
import com.bencodez.simpleapi.valuerequest.ValueRequest;
import com.bencodez.votingplugin.VotingPluginMain;
import lombok.Getter;
import lombok.Setter;
/**
* Admin vote shop categories GUI handler.
*/
public class AdminVoteVoteShopCategories extends GUIHandler {
@Getter
@Setter
private VotingPluginMain plugin;
/**
* Constructor for AdminVoteVoteShopCategories.
*
* @param plugin the VotingPluginMain instance
* @param player the command sender
*/
public AdminVoteVoteShopCategories(VotingPluginMain plugin, CommandSender player) {
super(plugin, player);
this.plugin = plugin;
}
@Override
public void onDialog(Player player) {
}
@Override
public ArrayList<String> getChat(CommandSender sender) {
return null;
}
@Override
public void onBook(Player player) {
}
@Override
public void onChat(CommandSender sender) {
}
@Override
public void onChest(Player player) {
BInventory inv = new BInventory("Edit VoteShop Categories");
inv.requirePermission("VotingPlugin.Commands.AdminVote.Edit.VoteShop");
ConfigurationSection categories = plugin.getShopFile().getCategoriesSection();
if (categories != null) {
for (String category : categories.getKeys(false)) {
ConfigurationSection categorySection = plugin.getShopFile().getCategorySection(category);
inv.addButton(new BInventoryButton(getCategoryItem(category, categorySection)) {
@Override
public void onClick(ClickEvent clickEvent) {
new AdminVoteVoteShopCategory(plugin, clickEvent.getPlayer(), (String) getData("category"))
.open(GUIMethod.CHEST);
}
}.addData("category", category));
}
}
inv.addButton(new BInventoryButton(new ItemBuilder(Material.EMERALD_BLOCK).setName("&aAdd category")) {
@Override
public void onClick(ClickEvent clickEvent) {
new ValueRequest(plugin, plugin.getDialogService(), null).requestString(clickEvent.getPlayer(), "", null, true,
"Enter category name", new StringListener() {
@Override
public void onInput(Player player, String value) {
createCategory(value, null);
player.sendMessage(MessageAPI.colorize("&aCategory created successfully: " + value));
plugin.reload();
}
});
}
});
inv.addButton(new BInventoryButton(new ItemBuilder(Material.HOPPER).setName("&aAdd category from hand")
.addLoreLine("&7Drag an item onto this button")
.addLoreLine("&7or click while holding an item.")) {
@Override
public void onClick(ClickEvent clickEvent) {
ItemStack itemStack = getHeldItem(clickEvent);
if (isEmpty(itemStack)) {
clickEvent.getPlayer().sendMessage(MessageAPI.colorize("&cHold an item in your main hand first."));
new AdminVoteVoteShopCategories(plugin, clickEvent.getPlayer()).open(GUIMethod.CHEST);
return;
}
new ValueRequest(plugin, plugin.getDialogService(), null).requestString(clickEvent.getPlayer(), "", null, true,
"Enter category name", new StringListener() {
@Override
public void onInput(Player player, String value) {
createCategory(value, itemStack);
player.sendMessage(MessageAPI.colorize("&aCategory created successfully: " + value));
plugin.reload();
}
});
}
});
inv.addButton(new BInventoryButton(new ItemBuilder(Material.REDSTONE_BLOCK).setName("&cRemove category")) {
@Override
public void onClick(ClickEvent clickEvent) {
new AdminVoteVoteShopCategoryRemove(plugin, clickEvent.getPlayer()).open(GUIMethod.CHEST);
}
});
inv.addButton(new BInventoryButton(new ItemBuilder(Material.BARRIER).setName("&cBack")) {
@Override
public void onClick(ClickEvent clickEvent) {
new AdminVoteVoteShop(plugin, clickEvent.getPlayer()).open(GUIMethod.CHEST);
}
});
inv.openInventory(player);
}
@Override
public void open() {
open(GUIMethod.CHEST);
}
/**
* Creates a category and matching main shop category button.
*
* @param category the category identifier
* @param itemStack optional display item
*/
private void createCategory(String category, ItemStack itemStack) {
String categoryPath = "Categories." + category;
String buttonPath = "Shop." + category;
plugin.getShopFile().getData().set(categoryPath + ".Name", "&b" + category);
plugin.getShopFile().getData().set(categoryPath + ".BackButton", true);
plugin.getShopFile().getData().set(categoryPath + ".BackButtonItem.Material", "BARRIER");
plugin.getShopFile().getData().set(categoryPath + ".BackButtonItem.Amount", 1);
plugin.getShopFile().getData().set(categoryPath + ".BackButtonItem.Slot", -2);
plugin.getShopFile().getData().set(categoryPath + ".BackButtonItem.Name", "&cBack");
plugin.getShopFile().getData().set(buttonPath + ".Category", category);
plugin.getShopFile().getData().set(buttonPath + ".Permission", "");
plugin.getShopFile().getData().set(buttonPath + ".HideOnNoPermission", true);
if (isEmpty(itemStack)) {
plugin.getShopFile().getData().set(buttonPath + ".DisplayItem.Material", "CHEST");
plugin.getShopFile().getData().set(buttonPath + ".DisplayItem.Amount", 1);
plugin.getShopFile().getData().set(buttonPath + ".DisplayItem.Name", "&b" + category);
} else {
ItemBuilder item = new ItemBuilder(itemStack.clone());
Map<String, Object> map = item.getConfiguration(true);
for (Entry<String, Object> entry : map.entrySet()) {
plugin.getShopFile().getData().set(buttonPath + ".DisplayItem.ItemStack." + entry.getKey(), entry.getValue());
}
}
if (!plugin.getShopFile().getData().isConfigurationSection(categoryPath + ".Shop")) {
plugin.getShopFile().getData().createSection(categoryPath + ".Shop");
}
plugin.getShopFile().saveData();
}
/**
* Gets a display item for a category.
*
* @param category the category identifier
* @param section the category section
* @return the display item
*/
private ItemBuilder getCategoryItem(String category, ConfigurationSection section) {
ConfigurationSection buttonSection = plugin.getShopFile().getData().getConfigurationSection("Shop." + category);
if (buttonSection != null) {
ConfigurationSection displaySection = plugin.getShopFile().getDisplaySection(buttonSection);
if (displaySection != null) {
return new ItemBuilder(displaySection).addLoreLine("&7Click to edit category");
}
}
String name = section == null ? "&b" + category : section.getString("Name", "&b" + category);
return new ItemBuilder(Material.CHEST).setName(name).addLoreLine("&7Click to edit category");
}
/**
* Gets the item the player is holding in their main hand.
*
* @param clickEvent the click event
* @return the held item
*/
private ItemStack getHeldItem(ClickEvent clickEvent) {
return clickEvent.getPlayer().getInventory().getItemInMainHand().clone();
}
/**
* Checks if an item stack is empty.
*
* @param itemStack the item stack
* @return true if empty
*/
private boolean isEmpty(ItemStack itemStack) {
return itemStack == null || itemStack.getType() == Material.AIR;
}
}
@@ -0,0 +1,383 @@
package com.bencodez.votingplugin.commands.gui.admin.voteshop;
import java.util.ArrayList;
import java.util.Map;
import java.util.Map.Entry;
import org.bukkit.Material;
import org.bukkit.command.CommandSender;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import com.bencodez.advancedcore.api.gui.GUIHandler;
import com.bencodez.advancedcore.api.gui.GUIMethod;
import com.bencodez.advancedcore.api.inventory.BInventory.ClickEvent;
import com.bencodez.advancedcore.api.inventory.BInventoryButton;
import com.bencodez.advancedcore.api.inventory.editgui.EditGUI;
import com.bencodez.advancedcore.api.inventory.editgui.EditGUIButton;
import com.bencodez.advancedcore.api.inventory.editgui.valuetypes.EditGUIValueBoolean;
import com.bencodez.advancedcore.api.inventory.editgui.valuetypes.EditGUIValueList;
import com.bencodez.advancedcore.api.inventory.editgui.valuetypes.EditGUIValueNumber;
import com.bencodez.advancedcore.api.inventory.editgui.valuetypes.EditGUIValueString;
import com.bencodez.advancedcore.api.item.ItemBuilder;
import com.bencodez.simpleapi.messages.MessageAPI;
import com.bencodez.simpleapi.valuerequest.StringListener;
import com.bencodez.simpleapi.valuerequest.ValueRequest;
import com.bencodez.votingplugin.VotingPluginMain;
import lombok.Getter;
import lombok.Setter;
/**
* Admin vote shop category GUI handler.
*/
public class AdminVoteVoteShopCategory extends GUIHandler {
@Getter
@Setter
private VotingPluginMain plugin;
@Getter
@Setter
private String category;
/**
* Constructor for AdminVoteVoteShopCategory.
*
* @param plugin the VotingPluginMain instance
* @param player the command sender
* @param category the category identifier
*/
public AdminVoteVoteShopCategory(VotingPluginMain plugin, CommandSender player, String category) {
super(plugin, player);
this.plugin = plugin;
this.category = category;
}
@Override
public void onDialog(Player player) {
}
@Override
public ArrayList<String> getChat(CommandSender sender) {
return null;
}
@Override
public void onBook(Player player) {
}
@Override
public void onChat(CommandSender sender) {
}
@Override
public void onChest(Player player) {
EditGUI inv = new EditGUI("Edit Category: " + category);
inv.requirePermission("VotingPlugin.Commands.AdminVote.Edit.VoteShop");
ConfigurationSection categorySection = getCategorySection();
ConfigurationSection backButton = getBackButtonSection();
ConfigurationSection buttonDisplay = getCategoryButtonDisplaySection();
inv.addButton(new EditGUIButton(new ItemBuilder(Material.PAPER, 1),
new EditGUIValueString("Name", categorySection.getString("Name", "&b" + category)) {
@Override
public void setValue(Player player, String name) {
setCategoryData(getKey(), name);
}
}));
inv.addButton(new EditGUIButton(new ItemBuilder(Material.BARRIER, 1),
new EditGUIValueBoolean("BackButton", categorySection.getBoolean("BackButton", true)) {
@Override
public void setValue(Player player, boolean value) {
setCategoryData(getKey(), value);
}
}));
inv.addButton(new EditGUIButton(new ItemBuilder(backButton.getString("Material", "BARRIER")),
new EditGUIValueString("BackButtonItem.Material", backButton.getString("Material", "BARRIER")) {
@Override
public void setValue(Player player, String name) {
setCategoryData(getKey(), name);
}
}));
inv.addButton(new EditGUIButton(new ItemBuilder(Material.PAPER, 1),
new EditGUIValueString("BackButtonItem.Name", backButton.getString("Name", "&cBack")) {
@Override
public void setValue(Player player, String name) {
setCategoryData(getKey(), name);
}
}));
inv.addButton(new EditGUIButton(new ItemBuilder(Material.BOOK, 1),
new EditGUIValueList("BackButtonItem.Lore", backButton.getStringList("Lore")) {
@Override
public void setValue(Player player, ArrayList<String> value) {
setCategoryData(getKey(), value);
}
}));
inv.addButton(new EditGUIButton(new ItemBuilder(Material.ITEM_FRAME, 1),
new EditGUIValueNumber("BackButtonItem.Slot", backButton.getInt("Slot", -2)) {
@Override
public void setValue(Player player, Number num) {
setCategoryData(getKey(), num.intValue());
}
}));
inv.addButton(new EditGUIButton(new ItemBuilder(buttonDisplay.getString("Material", "CHEST")),
new EditGUIValueString("CategoryButton.Material", buttonDisplay.getString("Material", "CHEST")) {
@Override
public void setValue(Player player, String name) {
setCategoryButtonDisplayData("Material", name);
}
}));
inv.addButton(new EditGUIButton(new ItemBuilder(Material.PAPER, 1),
new EditGUIValueString("CategoryButton.Name", buttonDisplay.getString("Name", "&b" + category)) {
@Override
public void setValue(Player player, String name) {
setCategoryButtonDisplayData("Name", name);
}
}));
inv.addButton(new EditGUIButton(new ItemBuilder(Material.BOOK, 1),
new EditGUIValueList("CategoryButton.Lore", buttonDisplay.getStringList("Lore")) {
@Override
public void setValue(Player player, ArrayList<String> value) {
setCategoryButtonDisplayData("Lore", value);
}
}));
inv.addButton(new BInventoryButton(new ItemBuilder(Material.HOPPER).setName("&aSet category button from hand")
.addLoreLine("&7Hold an item in your main hand")
.addLoreLine("&7then click this button.")) {
@Override
public void onClick(ClickEvent clickEvent) {
ItemStack itemStack = getHeldItem(clickEvent);
if (isEmpty(itemStack)) {
clickEvent.getPlayer().sendMessage(MessageAPI.colorize("&cHold an item in your main hand first."));
new AdminVoteVoteShopCategory(plugin, clickEvent.getPlayer(), category).open(GUIMethod.CHEST);
return;
}
setCategoryButtonItem(itemStack);
clickEvent.getPlayer().sendMessage(MessageAPI.colorize("&aUpdated category button item for " + category));
new AdminVoteVoteShopCategory(plugin, clickEvent.getPlayer(), category).open(GUIMethod.CHEST);
}
});
inv.addButton(new BInventoryButton(new ItemBuilder(Material.DIAMOND).setName("&cEdit category items")) {
@Override
public void onClick(ClickEvent clickEvent) {
new AdminVoteVoteShopItems(plugin, clickEvent.getPlayer(), category).open(GUIMethod.CHEST);
}
});
inv.addButton(new BInventoryButton(new ItemBuilder(Material.EMERALD_BLOCK).setName("&aAdd category item")) {
@Override
public void onClick(ClickEvent clickEvent) {
new ValueRequest(plugin, plugin.getDialogService(), null).requestString(clickEvent.getPlayer(), "", null, true,
"Enter item name", new StringListener() {
@Override
public void onInput(Player player, String value) {
createCategoryShopItem(value, null);
player.sendMessage(MessageAPI.colorize("&aCategory shop item created successfully: " + value));
plugin.reload();
}
});
}
});
inv.addButton(new BInventoryButton(new ItemBuilder(Material.HOPPER).setName("&aAdd category item from hand")
.addLoreLine("&7Hold an item in your main hand")
.addLoreLine("&7then click this button.")) {
@Override
public void onClick(ClickEvent clickEvent) {
ItemStack itemStack = getHeldItem(clickEvent);
if (isEmpty(itemStack)) {
clickEvent.getPlayer().sendMessage(MessageAPI.colorize("&cHold an item in your main hand first."));
new AdminVoteVoteShopCategory(plugin, clickEvent.getPlayer(), category).open(GUIMethod.CHEST);
return;
}
new ValueRequest(plugin, plugin.getDialogService(), null).requestString(clickEvent.getPlayer(), "", null, true,
"Enter item name", new StringListener() {
@Override
public void onInput(Player player, String value) {
createCategoryShopItem(value, itemStack);
player.sendMessage(MessageAPI.colorize("&aCategory shop item created successfully: " + value));
plugin.reload();
}
});
}
});
inv.addButton(new BInventoryButton(new ItemBuilder(Material.REDSTONE_BLOCK).setName("&cRemove category item")) {
@Override
public void onClick(ClickEvent clickEvent) {
new AdminVoteVoteShopItemRemove(plugin, clickEvent.getPlayer(), category).open(GUIMethod.CHEST);
}
});
inv.addButton(new BInventoryButton(new ItemBuilder(Material.BARRIER).setName("&cBack to categories")) {
@Override
public void onClick(ClickEvent clickEvent) {
new AdminVoteVoteShopCategories(plugin, clickEvent.getPlayer()).open(GUIMethod.CHEST);
}
});
inv.openInventory(player);
}
@Override
public void open() {
open(GUIMethod.CHEST);
}
/**
* Creates a category shop item.
*
* @param identifier the item identifier
* @param itemStack optional display item
*/
private void createCategoryShopItem(String identifier, ItemStack itemStack) {
String basePath = "Categories." + category + ".Shop." + identifier;
plugin.getShopFile().getData().set(basePath + ".Identifier_Name", identifier);
plugin.getShopFile().getData().set(basePath + ".Cost", 1);
plugin.getShopFile().getData().set(basePath + ".Permission", "");
plugin.getShopFile().getData().set(basePath + ".HideOnNoPermission", true);
plugin.getShopFile().getData().set(basePath + ".CloseGUI", true);
plugin.getShopFile().getData().set(basePath + ".RequireConfirmation", false);
plugin.getShopFile().getData().set(basePath + ".Rewards.Items.Item1.Material", "STONE");
plugin.getShopFile().getData().set(basePath + ".Rewards.Items.Item1.Amount", 1);
if (isEmpty(itemStack)) {
plugin.getShopFile().getData().set(basePath + ".DisplayItem.Material", "STONE");
plugin.getShopFile().getData().set(basePath + ".DisplayItem.Amount", 1);
plugin.getShopFile().getData().set(basePath + ".DisplayItem.Name", "&cPlaceholder item");
} else {
ItemBuilder item = new ItemBuilder(itemStack.clone());
Map<String, Object> map = item.getConfiguration(true);
for (Entry<String, Object> entry : map.entrySet()) {
plugin.getShopFile().getData().set(basePath + ".DisplayItem.ItemStack." + entry.getKey(), entry.getValue());
}
}
plugin.getShopFile().saveData();
}
/**
* Sets category data.
*
* @param path the relative path
* @param value the value
*/
private void setCategoryData(String path, Object value) {
plugin.getShopFile().getData().set("Categories." + category + "." + path, value);
plugin.getShopFile().saveData();
plugin.reload();
}
/**
* Sets category button display data.
*
* @param path the relative display path
* @param value the value
*/
private void setCategoryButtonDisplayData(String path, Object value) {
plugin.getShopFile().getData().set("Shop." + category + ".DisplayItem." + path, value);
plugin.getShopFile().saveData();
plugin.reload();
}
/**
* Sets the main shop category button item from an item stack.
*
* @param itemStack the item stack
*/
private void setCategoryButtonItem(ItemStack itemStack) {
ItemBuilder item = new ItemBuilder(itemStack.clone());
Map<String, Object> map = item.getConfiguration(true);
plugin.getShopFile().getData().set("Shop." + category + ".DisplayItem", null);
for (Entry<String, Object> entry : map.entrySet()) {
plugin.getShopFile().getData().set("Shop." + category + ".DisplayItem.ItemStack." + entry.getKey(), entry.getValue());
}
plugin.getShopFile().saveData();
plugin.reload();
}
/**
* Gets the category section.
*
* @return the category section
*/
private ConfigurationSection getCategorySection() {
ConfigurationSection section = plugin.getShopFile().getCategorySection(category);
if (section == null) {
section = plugin.getShopFile().getData().createSection("Categories." + category);
}
return section;
}
/**
* Gets the back button section.
*
* @return the back button section
*/
private ConfigurationSection getBackButtonSection() {
ConfigurationSection section = plugin.getShopFile().getData()
.getConfigurationSection("Categories." + category + ".BackButtonItem");
if (section == null) {
section = plugin.getShopFile().getData().createSection("Categories." + category + ".BackButtonItem");
}
return section;
}
/**
* Gets the category button display section.
*
* @return the display section
*/
private ConfigurationSection getCategoryButtonDisplaySection() {
ConfigurationSection section = plugin.getShopFile().getData()
.getConfigurationSection("Shop." + category + ".DisplayItem");
if (section == null) {
section = plugin.getShopFile().getData().createSection("Shop." + category + ".DisplayItem");
}
return section;
}
/**
* Gets the item the player is holding in their main hand.
*
* @param clickEvent the click event
* @return the held item
*/
private ItemStack getHeldItem(ClickEvent clickEvent) {
return clickEvent.getPlayer().getInventory().getItemInMainHand().clone();
}
/**
* Checks if an item stack is empty.
*
* @param itemStack the item stack
* @return true if empty
*/
private boolean isEmpty(ItemStack itemStack) {
return itemStack == null || itemStack.getType() == Material.AIR;
}
}
@@ -0,0 +1,119 @@
package com.bencodez.votingplugin.commands.gui.admin.voteshop;
import java.util.ArrayList;
import org.bukkit.command.CommandSender;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.entity.Player;
import com.bencodez.advancedcore.api.gui.GUIHandler;
import com.bencodez.advancedcore.api.gui.GUIMethod;
import com.bencodez.advancedcore.api.inventory.BInventory;
import com.bencodez.advancedcore.api.inventory.BInventory.ClickEvent;
import com.bencodez.advancedcore.api.inventory.BInventoryButton;
import com.bencodez.advancedcore.api.item.ItemBuilder;
import com.bencodez.votingplugin.VotingPluginMain;
import com.bencodez.votingplugin.commands.gui.admin.AdminVoteConfirmation;
import lombok.Getter;
import lombok.Setter;
/**
* Admin vote shop category remove GUI handler.
*/
public class AdminVoteVoteShopCategoryRemove extends GUIHandler {
@Getter
@Setter
private VotingPluginMain plugin;
/**
* Constructor for AdminVoteVoteShopCategoryRemove.
*
* @param plugin the VotingPluginMain instance
* @param player the command sender
*/
public AdminVoteVoteShopCategoryRemove(VotingPluginMain plugin, CommandSender player) {
super(plugin, player);
this.plugin = plugin;
}
@Override
public void onDialog(Player player) {
}
@Override
public ArrayList<String> getChat(CommandSender sender) {
return null;
}
@Override
public void onBook(Player player) {
}
@Override
public void onChat(CommandSender sender) {
}
@Override
public void onChest(Player player) {
BInventory inv = new BInventory("Remove VoteShop Category");
inv.requirePermission("VotingPlugin.Commands.AdminVote.Edit.VoteShop");
ConfigurationSection categories = plugin.getShopFile().getCategoriesSection();
if (categories != null) {
for (final String category : categories.getKeys(false)) {
inv.addButton(new BInventoryButton(getCategoryItem(category).addLoreLine("&c&lClick to remove")) {
@Override
public void onClick(ClickEvent clickEvent) {
new AdminVoteConfirmation(plugin, clickEvent.getPlayer(), "Remove category " + category + "?") {
@Override
public void onConfirm(Player p) {
plugin.getShopFile().getData().set("Categories." + category, null);
plugin.getShopFile().getData().set("Shop." + category, null);
plugin.getShopFile().saveData();
p.sendMessage("Removed category " + category);
plugin.reload();
}
@Override
public void onDeny(Player p) {
new AdminVoteVoteShopCategories(plugin, p).open();
}
}.open();
}
});
}
}
inv.openInventory(player);
}
@Override
public void open() {
open(GUIMethod.CHEST);
}
/**
* Gets a category display item.
*
* @param category the category identifier
* @return the display item
*/
private ItemBuilder getCategoryItem(String category) {
ConfigurationSection buttonSection = plugin.getShopFile().getData().getConfigurationSection("Shop." + category);
if (buttonSection != null) {
ConfigurationSection displaySection = plugin.getShopFile().getDisplaySection(buttonSection);
if (displaySection != null) {
return new ItemBuilder(displaySection);
}
}
ConfigurationSection categorySection = plugin.getShopFile().getCategorySection(category);
String name = categorySection == null ? "&b" + category : categorySection.getString("Name", "&b" + category);
return new ItemBuilder(org.bukkit.Material.CHEST).setName(name);
}
}
@@ -8,6 +8,7 @@ import org.bukkit.Material;
import org.bukkit.command.CommandSender;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import com.bencodez.advancedcore.api.gui.GUIHandler;
import com.bencodez.advancedcore.api.gui.GUIMethod;
@@ -20,15 +21,26 @@ import com.bencodez.advancedcore.api.inventory.editgui.valuetypes.EditGUIValueNu
import com.bencodez.advancedcore.api.inventory.editgui.valuetypes.EditGUIValueString;
import com.bencodez.advancedcore.api.item.ItemBuilder;
import com.bencodez.advancedcore.command.gui.RewardEditGUI;
import com.bencodez.simpleapi.messages.MessageAPI;
import com.bencodez.votingplugin.VotingPluginMain;
import lombok.Getter;
import lombok.Setter;
/**
* Admin vote shop item GUI handler.
*/
public class AdminVoteVoteShopItem extends GUIHandler {
@Getter
@Setter
private String identifier;
@Getter
@Setter
private VotingPluginMain plugin;
@Getter
@Setter
private String category;
/**
* Constructor for AdminVoteVoteShopItem.
@@ -38,9 +50,22 @@ public class AdminVoteVoteShopItem extends GUIHandler {
* @param identifier the item identifier
*/
public AdminVoteVoteShopItem(VotingPluginMain plugin, CommandSender player, String identifier) {
this(plugin, player, identifier, null);
}
/**
* Constructor for AdminVoteVoteShopItem.
*
* @param plugin the VotingPluginMain instance
* @param player the command sender
* @param identifier the item identifier
* @param category the category identifier
*/
public AdminVoteVoteShopItem(VotingPluginMain plugin, CommandSender player, String identifier, String category) {
super(plugin, player);
this.plugin = plugin;
this.identifier = identifier;
this.category = category;
}
@Override
@@ -66,9 +91,15 @@ public class AdminVoteVoteShopItem extends GUIHandler {
public void onChest(Player player) {
EditGUI inv = new EditGUI("Edit VoteShop Item: " + identifier);
inv.requirePermission("VotingPlugin.Commands.AdminVote.Edit.VoteShop");
// to add
ConfigurationSection shopItemData = getShopItemSection();
ConfigurationSection displayItemData = plugin.getShopFile().getDisplaySection(shopItemData);
if (displayItemData == null) {
displayItemData = shopItemData;
}
inv.addButton(new EditGUIButton(new ItemBuilder(Material.PAPER, 1), new EditGUIValueString("Identifier_Name",
plugin.getShopFile().getShopIdentifierIdentifierName(identifier)) {
shopItemData.getString("Identifier_Name", identifier)) {
@Override
public void setValue(Player player, String name) {
@@ -76,7 +107,7 @@ public class AdminVoteVoteShopItem extends GUIHandler {
}
}));
inv.addButton(new EditGUIButton(new ItemBuilder(Material.OAK_DOOR, 1),
new EditGUIValueString("Permission", plugin.getShopFile().getVoteShopPermission(identifier)) {
new EditGUIValueString("Permission", shopItemData.getString("Permission", "")) {
@Override
public void setValue(Player player, String name) {
@@ -84,7 +115,7 @@ public class AdminVoteVoteShopItem extends GUIHandler {
}
}));
inv.addButton(new EditGUIButton(new ItemBuilder(Material.EMERALD, 1),
new EditGUIValueNumber("Cost", plugin.getShopFile().getShopIdentifierCost(identifier)) {
new EditGUIValueNumber("Cost", shopItemData.getInt("Cost")) {
@Override
public void setValue(Player player, Number num) {
@@ -92,7 +123,7 @@ public class AdminVoteVoteShopItem extends GUIHandler {
}
}));
inv.addButton(new EditGUIButton(new ItemBuilder(Material.BARRIER, 1),
new EditGUIValueNumber("Limit", plugin.getShopFile().getShopIdentifierLimit(identifier)) {
new EditGUIValueNumber("Limit", shopItemData.getInt("Limit", -1)) {
@Override
public void setValue(Player player, Number num) {
@@ -100,10 +131,8 @@ public class AdminVoteVoteShopItem extends GUIHandler {
}
}));
// display item
ConfigurationSection displayItemData = plugin.getShopFile().getShopIdentifierSection(identifier);
inv.addButton(new EditGUIButton(new ItemBuilder(displayItemData.getString("Material")).setAmount(1),
new EditGUIValueString("Material", displayItemData.getString("Material")) {
inv.addButton(new EditGUIButton(new ItemBuilder(displayItemData.getString("Material", "STONE")).setAmount(1),
new EditGUIValueString(getDisplayPath("Material"), displayItemData.getString("Material", "STONE")) {
@Override
public void setValue(Player player, String name) {
@@ -111,7 +140,7 @@ public class AdminVoteVoteShopItem extends GUIHandler {
}
}));
inv.addButton(new EditGUIButton(new ItemBuilder(Material.PAPER, 1),
new EditGUIValueString("Name", displayItemData.getString("Name")) {
new EditGUIValueString(getDisplayPath("Name"), displayItemData.getString("Name", "")) {
@Override
public void setValue(Player player, String name) {
@@ -119,35 +148,42 @@ public class AdminVoteVoteShopItem extends GUIHandler {
}
}));
inv.addButton(new EditGUIButton(new ItemBuilder(Material.BOOK, 1),
new EditGUIValueList("Lore", displayItemData.getStringList("Lore")) {
new EditGUIValueList(getDisplayPath("Lore"), displayItemData.getStringList("Lore")) {
@Override
public void setValue(Player player, ArrayList<String> value) {
setPathData(getKey(), value);
}
}));
inv.addButton(new BInventoryButton(
new ItemBuilder(player.getInventory().getItemInMainHand().clone()).addLoreLine("&cClick to add")) {
inv.addButton(new BInventoryButton(new ItemBuilder(getPreviewItem(player)).addLoreLine("&cClick to copy held item display data")) {
@Override
public void onClick(ClickEvent clickEvent) {
ItemBuilder item = new ItemBuilder(clickEvent.getPlayer().getInventory().getItemInMainHand().clone());
Map<String, Object> map = item.getConfiguration(false);
for (Entry<String, Object> entry : map.entrySet()) {
setPathData(entry.getKey(), entry.getValue());
ItemStack itemStack = getHeldItem(clickEvent);
if (isEmpty(itemStack)) {
clickEvent.getPlayer().sendMessage(MessageAPI.colorize("&cHold an item in your main hand first."));
new AdminVoteVoteShopItem(plugin, clickEvent.getPlayer(), identifier, category).open(GUIMethod.CHEST);
return;
}
setDisplayItemData(itemStack);
clickEvent.getPlayer().sendMessage(MessageAPI.colorize("&aUpdated display item data for " + identifier));
new AdminVoteVoteShopItem(plugin, clickEvent.getPlayer(), identifier, category).open(GUIMethod.CHEST);
}
});
inv.addButton(new BInventoryButton(new ItemBuilder(player.getInventory().getItemInMainHand().clone())
.addLoreLine("&cClick to add with exact data")) {
inv.addButton(new BInventoryButton(new ItemBuilder(getPreviewItem(player)).addLoreLine("&cClick to copy held exact item data")) {
@Override
public void onClick(ClickEvent clickEvent) {
ItemBuilder item = new ItemBuilder(clickEvent.getPlayer().getInventory().getItemInMainHand().clone());
Map<String, Object> map = item.getConfiguration(true);
for (Entry<String, Object> entry : map.entrySet()) {
setPathData("ItemStack." + entry.getKey(), entry.getValue());
ItemStack itemStack = getHeldItem(clickEvent);
if (isEmpty(itemStack)) {
clickEvent.getPlayer().sendMessage(MessageAPI.colorize("&cHold an item in your main hand first."));
new AdminVoteVoteShopItem(plugin, clickEvent.getPlayer(), identifier, category).open(GUIMethod.CHEST);
return;
}
setItemData(itemStack, true);
clickEvent.getPlayer().sendMessage(MessageAPI.colorize("&aUpdated exact ItemStack data for " + identifier));
new AdminVoteVoteShopItem(plugin, clickEvent.getPlayer(), identifier, category).open(GUIMethod.CHEST);
}
});
@@ -156,12 +192,10 @@ public class AdminVoteVoteShopItem extends GUIHandler {
@Override
public void onClick(ClickEvent clickEvent) {
RewardEditGUI.getInstance().openRewardGUI(clickEvent.getPlayer(),
plugin.getRewardHandler().getDirectlyDefined("Shop." + identifier + ".Rewards"));
plugin.getRewardHandler().getDirectlyDefined(getBasePath() + ".Rewards"));
}
});
// implement item reward?
inv.openInventory(player);
}
@@ -170,10 +204,116 @@ public class AdminVoteVoteShopItem extends GUIHandler {
open(GUIMethod.CHEST);
}
/**
* Sets DisplayItem data using exact ItemStack serialization.
*
* @param itemStack the item stack to copy
*/
private void setDisplayItemData(ItemStack itemStack) {
ItemBuilder item = new ItemBuilder(itemStack.clone());
Map<String, Object> map = item.getConfiguration(true);
plugin.getShopFile().getData().set(getBasePath() + ".DisplayItem", null);
for (Entry<String, Object> entry : map.entrySet()) {
setPathData("DisplayItem.ItemStack." + entry.getKey(), entry.getValue());
}
}
/**
* Sets item display or exact ItemStack data.
*
* @param itemStack the item stack to copy
* @param exactData true to copy exact ItemStack data
*/
private void setItemData(ItemStack itemStack, boolean exactData) {
ItemBuilder item = new ItemBuilder(itemStack.clone());
Map<String, Object> map = item.getConfiguration(exactData);
for (Entry<String, Object> entry : map.entrySet()) {
setPathData(exactData ? "ItemStack." + entry.getKey() : getDisplayPath(entry.getKey()), entry.getValue());
}
}
/**
* Sets data for the current shop item.
*
* @param path the relative path
* @param value the value
*/
private void setPathData(String path, Object value) {
plugin.getShopFile().getData().set("Shop." + identifier + "." + path, value);
plugin.getShopFile().getData().set(getBasePath() + "." + path, value);
plugin.getShopFile().saveData();
plugin.reload();
}
/**
* Gets the base config path for this item.
*
* @return the config path
*/
private String getBasePath() {
if (category != null && !category.isEmpty()) {
return "Categories." + category + ".Shop." + identifier;
}
return "Shop." + identifier;
}
/**
* Gets the current shop item section.
*
* @return the shop item section
*/
private ConfigurationSection getShopItemSection() {
ConfigurationSection section = plugin.getShopFile().getData().getConfigurationSection(getBasePath());
if (section == null) {
section = plugin.getShopFile().getData().createSection(getBasePath());
}
return section;
}
/**
* Gets the display path for this item.
*
* @param path the display child path
* @return the relative display path
*/
private String getDisplayPath(String path) {
if (getShopItemSection().isConfigurationSection("DisplayItem")) {
return "DisplayItem." + path;
}
return path;
}
/**
* Gets a preview item for the current player.
*
* @param player the player
* @return the preview item
*/
private ItemStack getPreviewItem(Player player) {
ItemStack itemStack = player.getInventory().getItemInMainHand();
if (isEmpty(itemStack)) {
return new ItemStack(Material.HOPPER);
}
return itemStack.clone();
}
/**
* Gets the item the player is holding in their main hand.
*
* @param clickEvent the click event
* @return the held item
*/
private ItemStack getHeldItem(ClickEvent clickEvent) {
return clickEvent.getPlayer().getInventory().getItemInMainHand().clone();
}
/**
* Checks if an item stack is empty.
*
* @param itemStack the item stack
* @return true if empty
*/
private boolean isEmpty(ItemStack itemStack) {
return itemStack == null || itemStack.getType() == Material.AIR;
}
}
@@ -1,8 +1,11 @@
package com.bencodez.votingplugin.commands.gui.admin.voteshop;
import java.util.ArrayList;
import java.util.Set;
import org.bukkit.Material;
import org.bukkit.command.CommandSender;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.entity.Player;
import com.bencodez.advancedcore.api.gui.GUIHandler;
@@ -14,12 +17,20 @@ import com.bencodez.advancedcore.api.item.ItemBuilder;
import com.bencodez.votingplugin.VotingPluginMain;
import com.bencodez.votingplugin.commands.gui.admin.AdminVoteConfirmation;
import lombok.Getter;
import lombok.Setter;
/**
* Admin vote shop item remove GUI handler.
*/
public class AdminVoteVoteShopItemRemove extends GUIHandler {
@Getter
@Setter
private VotingPluginMain plugin;
@Getter
@Setter
private String category;
/**
* Constructor for AdminVoteVoteShopItemRemove.
@@ -28,8 +39,20 @@ public class AdminVoteVoteShopItemRemove extends GUIHandler {
* @param player the command sender
*/
public AdminVoteVoteShopItemRemove(VotingPluginMain plugin, CommandSender player) {
this(plugin, player, null);
}
/**
* Constructor for AdminVoteVoteShopItemRemove.
*
* @param plugin the VotingPluginMain instance
* @param player the command sender
* @param category the category identifier
*/
public AdminVoteVoteShopItemRemove(VotingPluginMain plugin, CommandSender player, String category) {
super(plugin, player);
this.plugin = plugin;
this.category = category;
}
@Override
@@ -53,34 +76,49 @@ public class AdminVoteVoteShopItemRemove extends GUIHandler {
@Override
public void onChest(Player player) {
BInventory inv = new BInventory("Edit VoteShop Remove Item");
BInventory inv = new BInventory(category == null ? "Edit VoteShop Remove Item" : "Remove Category Item: " + category);
inv.requirePermission("VotingPlugin.Commands.AdminVote.Edit.VoteShop");
for (final String identifier : plugin.getShopFile().getShopIdentifiers()) {
inv.addButton(
new BInventoryButton(new ItemBuilder(plugin.getShopFile().getShopIdentifierSection(identifier))
.addLoreLine("&c&lClick to remove")) {
for (final String identifier : getIdentifiers()) {
ConfigurationSection section = getItemSection(identifier);
if (section == null) {
continue;
}
inv.addButton(new BInventoryButton(new ItemBuilder(plugin.getShopFile().getDisplaySection(section))
.addLoreLine("&c&lClick to remove")) {
@Override
public void onClick(ClickEvent clickEvent) {
new AdminVoteConfirmation(plugin, clickEvent.getPlayer(), "Remove shop item " + identifier + "?") {
@Override
public void onClick(ClickEvent clickEvent) {
new AdminVoteConfirmation(plugin, clickEvent.getPlayer(),
"Remove shop item " + identifier + "?") {
@Override
public void onConfirm(Player p) {
plugin.getShopFile().removeShop(identifier);
p.sendMessage("Removed " + identifier);
plugin.reload();
}
@Override
public void onDeny(Player p) {
new AdminVoteVoteShop(plugin, p).open();
}
}.open();
public void onConfirm(Player p) {
removeShop(identifier);
p.sendMessage("Removed " + identifier);
plugin.reload();
}
}.addData("ident", identifier));
@Override
public void onDeny(Player p) {
if (category == null) {
new AdminVoteVoteShop(plugin, p).open();
} else {
new AdminVoteVoteShopCategory(plugin, p, category).open();
}
}
}.open();
}
}.addData("ident", identifier));
}
if (category != null) {
inv.addButton(new BInventoryButton(new ItemBuilder(Material.BARRIER).setName("&cBack to category")) {
@Override
public void onClick(ClickEvent clickEvent) {
new AdminVoteVoteShopCategory(plugin, clickEvent.getPlayer(), category).open(GUIMethod.CHEST);
}
});
}
inv.openInventory(player);
}
@@ -90,4 +128,47 @@ public class AdminVoteVoteShopItemRemove extends GUIHandler {
open(GUIMethod.CHEST);
}
/**
* Gets the identifiers to display.
*
* @return the identifiers
*/
private Set<String> getIdentifiers() {
if (category != null) {
ConfigurationSection section = plugin.getShopFile().getCategoryShopSection(category);
if (section != null) {
return section.getKeys(false);
}
return Set.of();
}
return plugin.getShopFile().getShopIdentifiers();
}
/**
* Gets the item section.
*
* @param identifier the item identifier
* @return the item section
*/
private ConfigurationSection getItemSection(String identifier) {
if (category != null) {
return plugin.getShopFile().getData().getConfigurationSection("Categories." + category + ".Shop." + identifier);
}
return plugin.getShopFile().getShopIdentifierSection(identifier);
}
/**
* Removes a shop item.
*
* @param identifier the identifier
*/
private void removeShop(String identifier) {
if (category == null) {
plugin.getShopFile().removeShop(identifier);
return;
}
plugin.getShopFile().getData().set("Categories." + category + ".Shop." + identifier, null);
plugin.getShopFile().saveData();
}
}
@@ -1,8 +1,11 @@
package com.bencodez.votingplugin.commands.gui.admin.voteshop;
import java.util.ArrayList;
import java.util.Set;
import org.bukkit.Material;
import org.bukkit.command.CommandSender;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.entity.Player;
import com.bencodez.advancedcore.api.gui.GUIHandler;
@@ -13,12 +16,20 @@ import com.bencodez.advancedcore.api.inventory.BInventoryButton;
import com.bencodez.advancedcore.api.item.ItemBuilder;
import com.bencodez.votingplugin.VotingPluginMain;
import lombok.Getter;
import lombok.Setter;
/**
* Admin vote shop items GUI handler.
*/
public class AdminVoteVoteShopItems extends GUIHandler {
@Getter
@Setter
private VotingPluginMain plugin;
@Getter
@Setter
private String category;
/**
* Constructor for AdminVoteVoteShopItems.
@@ -27,8 +38,20 @@ public class AdminVoteVoteShopItems extends GUIHandler {
* @param player the command sender
*/
public AdminVoteVoteShopItems(VotingPluginMain plugin, CommandSender player) {
this(plugin, player, null);
}
/**
* Constructor for AdminVoteVoteShopItems.
*
* @param plugin the VotingPluginMain instance
* @param player the command sender
* @param category the category identifier
*/
public AdminVoteVoteShopItems(VotingPluginMain plugin, CommandSender player, String category) {
super(plugin, player);
this.plugin = plugin;
this.category = category;
}
@Override
@@ -52,20 +75,33 @@ public class AdminVoteVoteShopItems extends GUIHandler {
@Override
public void onChest(Player player) {
BInventory inv = new BInventory("Edit VoteShop Items");
BInventory inv = new BInventory(category == null ? "Edit VoteShop Items" : "Edit Category Items: " + category);
inv.requirePermission("VotingPlugin.Commands.AdminVote.Edit.VoteShop");
for (String identifier : plugin.getShopFile().getShopIdentifiers()) {
inv.addButton(
new BInventoryButton(new ItemBuilder(plugin.getShopFile().getShopIdentifierSection(identifier))) {
for (String identifier : getIdentifiers()) {
ConfigurationSection section = getItemSection(identifier);
if (section == null) {
continue;
}
inv.addButton(new BInventoryButton(new ItemBuilder(plugin.getShopFile().getDisplaySection(section))) {
@Override
public void onClick(ClickEvent clickEvent) {
new AdminVoteVoteShopItem(plugin, clickEvent.getPlayer(), (String) getData("ident"))
.open(GUIMethod.CHEST);
@Override
public void onClick(ClickEvent clickEvent) {
new AdminVoteVoteShopItem(plugin, clickEvent.getPlayer(), (String) getData("ident"), category)
.open(GUIMethod.CHEST);
}
}.addData("ident", identifier));
}
}.addData("ident", identifier));
}
if (category != null) {
inv.addButton(new BInventoryButton(new ItemBuilder(Material.BARRIER).setName("&cBack to category")) {
@Override
public void onClick(ClickEvent clickEvent) {
new AdminVoteVoteShopCategory(plugin, clickEvent.getPlayer(), category).open(GUIMethod.CHEST);
}
});
}
inv.openInventory(player);
@@ -76,4 +112,33 @@ public class AdminVoteVoteShopItems extends GUIHandler {
open(GUIMethod.CHEST);
}
/**
* Gets the identifiers to display.
*
* @return the identifiers
*/
private Set<String> getIdentifiers() {
if (category != null) {
ConfigurationSection section = plugin.getShopFile().getCategoryShopSection(category);
if (section != null) {
return section.getKeys(false);
}
return Set.of();
}
return plugin.getShopFile().getShopIdentifiers();
}
/**
* Gets the item section.
*
* @param identifier the item identifier
* @return the item section
*/
private ConfigurationSection getItemSection(String identifier) {
if (category != null) {
return plugin.getShopFile().getData().getConfigurationSection("Categories." + category + ".Shop." + identifier);
}
return plugin.getShopFile().getShopIdentifierSection(identifier);
}
}
@@ -171,7 +171,7 @@ public class ServerData {
*/
@SuppressWarnings("unchecked")
public ArrayList<String> getServiceSites() {
return (ArrayList<String>) getData().getList("ServiceSites", new ArrayList<>());
return (ArrayList<String>) getData().getList("GottenServiceSites", new ArrayList<>());
}
/**
@@ -434,7 +434,7 @@ public class ServerData {
* @param list the service sites list
*/
public void setServiceSites(ArrayList<String> list) {
getData().set("ServiceSites", list);
getData().set("GottenServiceSites", list);
saveData();
}
@@ -145,7 +145,9 @@ public class PlayerVoteListener implements Listener {
}
}
if (voteSite.isWaitUntilVoteDelay() && !user.canVoteSite(voteSite)) {
boolean queuedProxyVoteAlreadyRecorded = ProxyVoteDelayCheck.isQueuedVoteAlreadyRecorded(event.isBungee(),
event.getTime(), user.getTime(voteSite));
if (voteSite.isWaitUntilVoteDelay() && !queuedProxyVoteAlreadyRecorded && !user.canVoteSite(voteSite)) {
if (!event.isRealVote()) {
plugin.getLogger().info(user.getPlayerName() + " did a not real vote, bypassing WaitUntilVoteDelay");
} else {
@@ -157,6 +159,10 @@ public class PlayerVoteListener implements Listener {
.info(user.getPlayerName() + " has bypass permission for WaitUntilVoteDelay, bypassing");
}
}
if (queuedProxyVoteAlreadyRecorded) {
plugin.debug("Allowing queued proxy vote for " + user.getPlayerName() + " on " + voteSite.getKey()
+ "; proxy vote time already matches LastVotes: " + event.getTime());
}
UUID voteUUID = UUID.randomUUID();
if (event.isBungee() && event.getBungeeTextTotals() != null) {
@@ -0,0 +1,24 @@
package com.bencodez.votingplugin.listeners;
/**
* Handles the WaitUntilVoteDelay exception for votes queued by a proxy.
*/
public final class ProxyVoteDelayCheck {
private ProxyVoteDelayCheck() {
}
/**
* A proxy stores the real vote timestamp before its queued message reaches the
* backend. The backend must accept that one delivery when the stored timestamp
* is the same timestamp carried by the message.
*
* @param proxyVote true when the vote was forwarded by VotingPlugin's proxy
* @param messageVoteTime real vote time included in the proxy message
* @param storedVoteTime current backend last-vote time for the site
* @return true when the delay check should allow the queued delivery
*/
public static boolean isQueuedVoteAlreadyRecorded(boolean proxyVote, long messageVoteTime, long storedVoteTime) {
return proxyVote && messageVoteTime > 0L && messageVoteTime == storedVoteTime;
}
}
@@ -332,6 +332,15 @@ public class PlaceHolders {
}.withDescription("Last month total").updateDataKey("LastMonthTotal"));
// end of older placeholders
placeholders.add(new PlaceHolder<VotingPluginUser>("CanLike_NameMC") {
@Override
public String placeholderRequest(VotingPluginUser user, String identifier) {
return user.hasClaimedNameMCLikeReward() ? "Complete" : "Incomplete";
}
}.withDescription("Return Complete/Incomplete depending on whether the player has liked the server on NameMC")
.updateDataKey("ClaimedNameMCLikeReward"));
placeholders.add(new PlaceHolder<VotingPluginUser>("DisableBroadcast") {
@@ -98,7 +98,7 @@ public class VoteSiteJsonPresetManager {
String voteURL = getString(values.get("voteURL"));
String voteDelay = getString(values.get("voteDelay"));
Boolean waitUntilDelay = getBoolean(values.get("WaitUnitVoteDelay"));
Boolean waitUntilDelay = getBoolean(values.get("WaitUntilVoteDelay"));
if (waitUntilDelay == null) {
waitUntilDelay = getBoolean(values.get("waitUntilVoteDelay"));
}
@@ -42,11 +42,25 @@ public class VoteTotalsSnapshot {
private int dateMonthTotal;
@Getter
@Setter
@Deprecated
private UUID voteUUID;
public VoteTotalsSnapshot() {
}
public VoteTotalsSnapshot(int allTimeTotal, int monthTotal, int weeklyTotal, int dailyTotal, int points,
int votePartyCurrent, int votePartyRequired, int dateMonthTotal) {
this.allTimeTotal = allTimeTotal;
this.monthTotal = monthTotal;
this.weeklyTotal = weeklyTotal;
this.dailyTotal = dailyTotal;
this.points = points;
this.votePartyCurrent = votePartyCurrent;
this.votePartyRequired = votePartyRequired;
this.dateMonthTotal = dateMonthTotal;
}
@Deprecated
public VoteTotalsSnapshot(int allTimeTotal, int monthTotal, int weeklyTotal, int dailyTotal, int points,
int votePartyCurrent, int votePartyRequired, int dateMonthTotal, UUID voteUUID) {
this.allTimeTotal = allTimeTotal;
@@ -102,8 +116,7 @@ public class VoteTotalsSnapshot {
public String toStorageString() {
return "v" + CURRENT_VERSION + SEP + allTimeTotal + SEP + monthTotal + SEP + weeklyTotal + SEP + dailyTotal
+ SEP + points + SEP + votePartyCurrent + SEP + votePartyRequired + SEP + dateMonthTotal + SEP
+ (voteUUID != null ? voteUUID.toString() : "");
+ SEP + points + SEP + votePartyCurrent + SEP + votePartyRequired + SEP + dateMonthTotal;
}
@Override
@@ -508,8 +508,8 @@ public abstract class VotingPluginProxy {
globalMessageProxyHandler.sendMessage(server, delay,
VotingPluginWire.vote(cache.getPlayerName(), cache.getUuid(),
cache.getService(), cache.getTime(), false, cache.isRealVote(),
cache.getText(), getConfig().getBungeeManageTotals(), broadcastHere,
num, numberOfVotes));
cache.getText(), cache.getVoteId(), getConfig().getBungeeManageTotals(),
broadcastHere, num, numberOfVotes));
delay++;
num++;
removed.add(cache);
@@ -553,7 +553,7 @@ public abstract class VotingPluginProxy {
globalMessageProxyHandler.sendMessage(server, delay,
VotingPluginWire.voteOnline(cache.getPlayerName(), cache.getUuid(), cache.getService(),
cache.getTime(), false, cache.isRealVote(), cache.getText(),
cache.getTime(), false, cache.isRealVote(), cache.getText(), cache.getVoteId(),
getConfig().getBungeeManageTotals(), broadcastHere, num, numberOfVotes));
delay++;
num++;
@@ -1342,7 +1342,7 @@ public abstract class VotingPluginProxy {
public void processQueue() {
while (getVoteCacheHandler().getTimeChangeQueue().size() > 0) {
VoteTimeQueue vote = getVoteCacheHandler().getTimeChangeQueue().remove();
vote(vote.getName(), vote.getService(), true, false, vote.getTime(), null, null);
vote(vote.getName(), vote.getService(), true, false, vote.getTime(), null, null, vote.getVoteId());
}
}
@@ -1597,7 +1597,16 @@ public abstract class VotingPluginProxy {
public synchronized void vote(String player, String service, boolean realVote, boolean timeQueue, long queueTime,
VoteTotalsSnapshot text, String uuid) {
vote(player, service, realVote, timeQueue, queueTime, text, uuid, null);
}
private synchronized void vote(String player, String service, boolean realVote, boolean timeQueue, long queueTime,
VoteTotalsSnapshot text, String uuid, UUID existingVoteId) {
try {
UUID voteId = existingVoteId;
if (voteId == null) {
voteId = UUID.randomUUID();
}
if (player == null || player.isEmpty()) {
log("No name from vote on " + service);
return;
@@ -1608,7 +1617,7 @@ public abstract class VotingPluginProxy {
if (getGlobalDataHandler().isTimeChangedHappened()) {
getGlobalDataHandler().checkForFinishedTimeChanges();
if (timeQueue && getGlobalDataHandler().isTimeChangedHappened()) {
getVoteCacheHandler().getTimeChangeQueue().add(new VoteTimeQueue(player, service,
getVoteCacheHandler().getTimeChangeQueue().add(new VoteTimeQueue(voteId, player, service,
LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()));
log("Caching vote from " + player + "/" + service
+ " because time change is happening right now");
@@ -1684,8 +1693,6 @@ public abstract class VotingPluginProxy {
final boolean playerOnline = isPlayerOnline(player);
final String playerServer = playerOnline ? getCurrentPlayerServer(player) : null;
final UUID voteId = UUID.randomUUID();
addVoteParty();
// Totals processing (primary server OR no multiproxy)
@@ -1740,7 +1747,7 @@ public abstract class VotingPluginProxy {
}
text = new VoteTotalsSnapshot(allTimeTotal, monthTotal, weeklyTotal, dailyTotal, points,
votePartyVotes, currentVotePartyVotesRequired, dateMonthTotal, voteId);
votePartyVotes, currentVotePartyVotesRequired, dateMonthTotal);
ArrayList<Column> update = new ArrayList<>();
update.add(new Column("AllTimeTotal", new DataValueInt(allTimeTotal)));
@@ -1752,11 +1759,11 @@ public abstract class VotingPluginProxy {
update.add(new Column("DailyTotal", new DataValueInt(dailyTotal)));
update.add(new Column("Points", new DataValueInt(points)));
debug("Setting totals " + text.toString());
debug("Setting totals " + text.toString() + ", voteId=" + voteId + " for " + player + "/"
+ service);
getProxyMySQL().update(uuid, update);
} else {
text = new VoteTotalsSnapshot(0, 0, 0, 0, 0, votePartyVotes, currentVotePartyVotesRequired, 0,
voteId);
text = new VoteTotalsSnapshot(0, 0, 0, 0, 0, votePartyVotes, currentVotePartyVotesRequired, 0);
}
}
@@ -1773,10 +1780,11 @@ public abstract class VotingPluginProxy {
if (getConfig().getSendVotesToAllServers()) {
for (String s : getAllAvailableServers()) {
boolean forceCache = false;
if (!playerOnline && getConfig().getWaitForUserOnline()) {
forceCache = true;
debug("Forcing vote to cache");
boolean forceCache = getConfig().getWaitForUserOnline()
&& (!playerOnline || playerServer == null || !playerServer.equalsIgnoreCase(s));
if (forceCache) {
debug("Forcing vote to cache for server " + s);
}
if ((!isSomeoneOnlineServer(s) && method.requiresPlayerOnline()) || forceCache) {
@@ -1793,7 +1801,7 @@ public abstract class VotingPluginProxy {
globalMessageProxyHandler.sendMessage(s, 2,
VotingPluginWire.vote(player, uuid, service, time, true, realVote, text.toString(),
!getConfig().getBungeeManageTotals(), broadcastHere, 1, 1));
voteId, getConfig().getBungeeManageTotals(), broadcastHere, 1, 1));
}
}
} else {
@@ -1810,7 +1818,7 @@ public abstract class VotingPluginProxy {
globalMessageProxyHandler.sendMessage(server, 1,
VotingPluginWire.voteOnline(player, uuid, service, time, true, realVote, text.toString(),
!getConfig().getBungeeManageTotals(), broadcastHere, 1, 1));
voteId, getConfig().getBungeeManageTotals(), broadcastHere, 1, 1));
if (getConfig().getProxyBroadcastEnabled()) {
Set<String> targets = proxyBroadcastDecider.resolveTargets(true, playerServer);
@@ -1863,7 +1871,7 @@ public abstract class VotingPluginProxy {
if (!getConfig().getMultiProxyOneGlobalReward()) {
debug("Sending global proxy vote envelope");
multiProxyHandler.sendMultiProxyEnvelope(VotingPluginWire.vote(player, uuid, service, time, false,
realVote, text == null ? "" : text.toString(), false, false, 1, 1));
realVote, text == null ? "" : text.toString(), voteId, false, false, 1, 1));
} else {
// Only send to other proxies if the player DID NOT already receive reward on a
// backend
@@ -1876,8 +1884,9 @@ public abstract class VotingPluginProxy {
if (shouldSend) {
debug("Sending global proxy voteonline envelope");
multiProxyHandler.sendMultiProxyEnvelope(VotingPluginWire.voteOnline(player, uuid, service,
time, false, realVote, text == null ? "" : text.toString(), false, false, 1, 1));
multiProxyHandler
.sendMultiProxyEnvelope(VotingPluginWire.voteOnline(player, uuid, service, time, false,
realVote, text == null ? "" : text.toString(), voteId, false, false, 1, 1));
} else {
debug("Not sending global proxy message for voteonline, player already got reward");
}
@@ -2,6 +2,7 @@
package com.bencodez.votingplugin.proxy;
import java.util.Map;
import java.util.UUID;
import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope;
@@ -63,6 +64,7 @@ public final class VotingPluginWire {
public static final String K_WAS_ONLINE = "wasOnline";
public static final String K_REAL_VOTE = "realVote";
public static final String K_TOTALS = "totals";
public static final String K_VOTE_ID = "voteId";
public static final String K_SET_TOTALS = "setTotals";
public static final String K_MANAGE_TOTALS = "manageTotals";
public static final String K_BUNGEE_BROADCAST = "bungeeBroadcast";
@@ -84,22 +86,24 @@ public final class VotingPluginWire {
}
public static JsonEnvelope vote(String player, String uuid, String service, long time, boolean wasOnline,
boolean realVote, String totals, boolean manageTotals, boolean bungeeBroadcast, int num,
boolean realVote, String totals, UUID voteId, boolean manageTotals, boolean bungeeBroadcast, int num,
int numberOfVotes) {
return base(SUB_VOTE).put(K_PLAYER, safe(player)).put(K_UUID, safe(uuid)).put(K_SERVICE, safe(service))
.put(K_TIME, time).put(K_WAS_ONLINE, wasOnline).put(K_REAL_VOTE, realVote).put(K_TOTALS, safe(totals))
.put(K_VOTE_ID, voteId == null ? "" : voteId.toString())
.put(K_SET_TOTALS, true) // backend historically defaulted to true
.put(K_MANAGE_TOTALS, manageTotals).put(K_BUNGEE_BROADCAST, bungeeBroadcast).put(K_NUM, num)
.put(K_NUMBER_OF_VOTES, numberOfVotes).build();
}
public static JsonEnvelope voteOnline(String player, String uuid, String service, long time, boolean wasOnline,
boolean realVote, String totals, boolean manageTotals, boolean bungeeBroadcast, int num,
boolean realVote, String totals, UUID voteId, boolean manageTotals, boolean bungeeBroadcast, int num,
int numberOfVotes) {
return base(SUB_VOTE_ONLINE).put(K_PLAYER, safe(player)).put(K_UUID, safe(uuid)).put(K_SERVICE, safe(service))
.put(K_TIME, time).put(K_WAS_ONLINE, wasOnline).put(K_REAL_VOTE, realVote).put(K_TOTALS, safe(totals))
.put(K_VOTE_ID, voteId == null ? "" : voteId.toString())
.put(K_SET_TOTALS, true).put(K_MANAGE_TOTALS, manageTotals).put(K_BUNGEE_BROADCAST, bungeeBroadcast)
.put(K_NUM, num).put(K_NUMBER_OF_VOTES, numberOfVotes).build();
}
@@ -174,6 +178,7 @@ public final class VotingPluginWire {
public final boolean wasOnline;
public final boolean realVote;
public final String totals;
public final UUID voteId;
public final boolean setTotals;
public final boolean manageTotals;
@@ -182,7 +187,7 @@ public final class VotingPluginWire {
public final int numberOfVotes;
private Vote(String subChannel, String player, String uuid, String service, long time, boolean wasOnline,
boolean realVote, String totals, boolean setTotals, boolean manageTotals, boolean broadcast, int num,
boolean realVote, String totals, UUID voteId, boolean setTotals, boolean manageTotals, boolean broadcast, int num,
int numberOfVotes) {
this.subChannel = subChannel;
this.player = player;
@@ -192,6 +197,7 @@ public final class VotingPluginWire {
this.wasOnline = wasOnline;
this.realVote = realVote;
this.totals = totals;
this.voteId = voteId;
this.setTotals = setTotals;
this.manageTotals = manageTotals;
this.broadcast = broadcast;
@@ -212,6 +218,7 @@ public final class VotingPluginWire {
final boolean wasOnline = readBool(f, K_WAS_ONLINE, false);
final boolean realVote = readBool(f, K_REAL_VOTE, false);
final String totals = safe(f.get(K_TOTALS));
final UUID voteId = readUuid(f, K_VOTE_ID);
final boolean setTotals = readBool(f, K_SET_TOTALS, true);
final boolean manageTotals = readBool(f, K_MANAGE_TOTALS, false);
@@ -221,7 +228,7 @@ public final class VotingPluginWire {
final int num = readInt(f, K_NUM, 1);
final int numberOfVotes = readInt(f, K_NUMBER_OF_VOTES, 1);
return new Vote(sub, player, uuid, service, time, wasOnline, realVote, totals, setTotals, manageTotals,
return new Vote(sub, player, uuid, service, time, wasOnline, realVote, totals, voteId, setTotals, manageTotals,
broadcast, num, numberOfVotes);
}
@@ -294,6 +301,18 @@ public final class VotingPluginWire {
}
}
private static UUID readUuid(Map<String, String> f, String key) {
String value = f.get(key);
if (value == null || value.isEmpty()) {
return null;
}
try {
return UUID.fromString(value);
} catch (IllegalArgumentException ignored) {
return null;
}
}
private static long readLong(Map<String, String> f, String key, long def) {
String v = f.get(key);
if (v == null) {
@@ -40,6 +40,7 @@ public class BungeeJsonVoteCache extends BungeeJsonFile implements IVoteCache {
setString(path + ".Name", voteTimedQueue.getName());
setString(path + ".Service", voteTimedQueue.getService());
setLong(path + ".Time", voteTimedQueue.getTime());
setString(path + ".VoteId", voteTimedQueue.getVoteId() == null ? null : voteTimedQueue.getVoteId().toString());
}
public void addVote(String server, int num, OfflineBungeeVote voteData) {
@@ -50,7 +51,7 @@ public class BungeeJsonVoteCache extends BungeeJsonFile implements IVoteCache {
setLong(path + ".Time", voteData.getTime());
setBoolean(path + ".Real", voteData.isRealVote());
setString(path + ".Text", voteData.getText());
setString(path + ".VoteID", voteData.getVoteId() != null ? voteData.getVoteId().toString() : null);
setString(path + ".VoteId", voteData.getVoteId() != null ? voteData.getVoteId().toString() : null);
}
public void addVoteOnline(String player, int num, OfflineBungeeVote voteData) {
@@ -61,7 +62,7 @@ public class BungeeJsonVoteCache extends BungeeJsonFile implements IVoteCache {
setLong(path + ".Time", voteData.getTime());
setBoolean(path + ".Real", voteData.isRealVote());
setString(path + ".Text", voteData.getText());
setString(path + ".VoteID", voteData.getVoteId() != null ? voteData.getVoteId().toString() : null);
setString(path + ".VoteId", voteData.getVoteId() != null ? voteData.getVoteId().toString() : null);
}
public void clearData() {
@@ -6,6 +6,7 @@ import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import com.bencodez.simpleapi.sql.mysql.AbstractSqlTable;
import com.bencodez.simpleapi.sql.mysql.DbType;
@@ -30,7 +31,8 @@ public abstract class ProxyTimedVoteCacheTable extends AbstractSqlTable {
+ qi("id") + " BIGSERIAL PRIMARY KEY, "
+ qi("playerName") + " VARCHAR(100), "
+ qi("service") + " VARCHAR(100), "
+ qi("time") + " BIGINT"
+ qi("time") + " BIGINT, "
+ qi("voteId") + " VARCHAR(36)"
+ ");";
}
@@ -39,6 +41,7 @@ public abstract class ProxyTimedVoteCacheTable extends AbstractSqlTable {
+ qi("playerName") + " VARCHAR(100),"
+ qi("service") + " VARCHAR(100),"
+ qi("time") + " BIGINT,"
+ qi("voteId") + " VARCHAR(36),"
+ "INDEX idx_time (" + qi("time") + ")"
+ ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;";
}
@@ -62,6 +65,7 @@ public abstract class ProxyTimedVoteCacheTable extends AbstractSqlTable {
super((tablePrefix != null ? tablePrefix : "") + "votingplugin_timedvotecache",
existingMysql,
debug);
ensureVoteIdColumn();
ensureIndexes();
}
@@ -72,9 +76,28 @@ public abstract class ProxyTimedVoteCacheTable extends AbstractSqlTable {
*/
public ProxyTimedVoteCacheTable(MysqlConfig config, boolean debug) {
super("votingplugin_timedvotecache", config, debug);
ensureVoteIdColumn();
ensureIndexes();
}
private void ensureVoteIdColumn() {
String probeSql = "SELECT " + qi("voteId") + " FROM " + qi(getTableName()) + " WHERE 1 = 0;";
try (Connection conn = mysql.getConnectionManager().getConnection();
PreparedStatement ps = conn.prepareStatement(probeSql)) {
ps.executeQuery();
return;
} catch (SQLException ignored) {
// Column does not exist yet.
}
try {
new Query(mysql, "ALTER TABLE " + qi(getTableName()) + " ADD COLUMN " + qi("voteId")
+ " VARCHAR(36);").executeUpdate();
} catch (SQLException e) {
debug(e);
}
}
private void ensureIndexes() {
if (getDbType() == DbType.POSTGRESQL) {
try {
@@ -89,18 +112,20 @@ public abstract class ProxyTimedVoteCacheTable extends AbstractSqlTable {
// --- INSERT ---
/**
* Inserts a timed vote.
* @param voteId unique vote identifier
* @param playerName the player name
* @param service the voting service
* @param time the vote time
*/
public void insertTimedVote(String playerName, String service, long time) {
public void insertTimedVote(UUID voteId, String playerName, String service, long time) {
String sql = "INSERT INTO " + qi(getTableName()) + " (" + qi("playerName") + ", " + qi("service") + ", "
+ qi("time") + ") VALUES (?, ?, ?);";
+ qi("time") + ", " + qi("voteId") + ") VALUES (?, ?, ?, ?);";
try (Connection conn = mysql.getConnectionManager().getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, playerName);
ps.setString(2, service);
ps.setLong(3, time);
ps.setString(4, voteId == null ? null : voteId.toString());
ps.executeUpdate();
} catch (SQLException e) {
debug(e);
@@ -189,7 +214,8 @@ public abstract class ProxyTimedVoteCacheTable extends AbstractSqlTable {
rs.getInt("id"),
rs.getString("playerName"),
rs.getString("service"),
rs.getLong("time")
rs.getLong("time"),
parseUuid(rs.getString("voteId"))
));
}
}
@@ -199,6 +225,17 @@ public abstract class ProxyTimedVoteCacheTable extends AbstractSqlTable {
return list;
}
private UUID parseUuid(String value) {
if (value == null || value.isEmpty()) {
return null;
}
try {
return UUID.fromString(value);
} catch (IllegalArgumentException ignored) {
return null;
}
}
/**
* Represents a row in the timed vote cache table.
*/
@@ -207,6 +244,7 @@ public abstract class ProxyTimedVoteCacheTable extends AbstractSqlTable {
private final String playerName;
private final String service;
private final long time;
private final UUID voteId;
/**
* Constructor for TimedVoteRow.
@@ -214,12 +252,14 @@ public abstract class ProxyTimedVoteCacheTable extends AbstractSqlTable {
* @param playerName the player name
* @param service the voting service
* @param time the vote time
* @param voteId unique vote identifier
*/
public TimedVoteRow(int id, String playerName, String service, long time) {
public TimedVoteRow(int id, String playerName, String service, long time, UUID voteId) {
this.id = id;
this.playerName = playerName;
this.service = service;
this.time = time;
this.voteId = voteId;
}
/**
@@ -253,5 +293,14 @@ public abstract class ProxyTimedVoteCacheTable extends AbstractSqlTable {
public long getTime() {
return time;
}
/**
* Gets the vote identifier.
*
* @return vote identifier or null
*/
public UUID getVoteId() {
return voteId;
}
}
}
@@ -6,6 +6,7 @@ import java.util.ArrayList;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Queue;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedQueue;
@@ -87,7 +88,12 @@ public abstract class VoteCacheHandler {
* @param server the server name
* @param vote the vote to add
*/
public void addServerVote(String server, OfflineBungeeVote vote) {
public synchronized void addServerVote(String server, OfflineBungeeVote vote) {
if (containsServerVote(server, vote.getVoteId())) {
debug1("Not caching duplicate vote " + vote.getVoteId() + " for server " + server);
return;
}
cachedVotes.putIfAbsent(server, new ArrayList<>());
cachedVotes.get(server).add(vote);
@@ -159,7 +165,12 @@ public abstract class VoteCacheHandler {
* @param uuid the player UUID
* @param vote the vote to add
*/
public void addOnlineVote(String uuid, OfflineBungeeVote vote) {
public synchronized void addOnlineVote(String uuid, OfflineBungeeVote vote) {
if (containsOnlineVote(uuid, vote.getVoteId())) {
debug1("Not caching duplicate online vote " + vote.getVoteId() + " for " + uuid);
return;
}
cachedOnlineVotes.putIfAbsent(uuid, new ArrayList<>());
cachedOnlineVotes.get(uuid).add(vote);
@@ -226,6 +237,82 @@ public abstract class VoteCacheHandler {
}
}
/**
* Checks whether a vote is already cached for a server.
*
* @param server target server
* @param voteId unique vote identifier
* @return true if the vote is already cached for the server
*/
private boolean containsServerVote(String server, UUID voteId) {
if (voteId == null) {
return false;
}
for (OfflineBungeeVote vote : getVotes(server)) {
if (voteId.equals(vote.getVoteId())) {
return true;
}
}
return false;
}
/**
* Checks whether a vote is already cached for an online player.
*
* @param uuid player UUID
* @param voteId unique vote identifier
* @return true if the vote is already cached for the player
*/
private boolean containsOnlineVote(String uuid, UUID voteId) {
if (voteId == null) {
return false;
}
for (OfflineBungeeVote vote : getOnlineVotes(uuid)) {
if (voteId.equals(vote.getVoteId())) {
return true;
}
}
return false;
}
/**
* Reads a vote identifier using the current key and the legacy key.
*
* @param data cached vote data
* @return stored vote identifier or an empty string
*/
private String readVoteId(DataNode data) {
if (data.has("VoteId")) {
return data.get("VoteId").asString();
}
if (data.has("VoteID")) {
return data.get("VoteID").asString();
}
return "";
}
/**
* Reads an optional UUID from cached data.
*
* @param data cached data
* @param key value key
* @return parsed UUID or null
*/
private UUID readUuid(DataNode data, String key) {
if (!data.has(key)) {
return null;
}
String value = data.get(key).asString();
if (value == null || value.isEmpty()) {
return null;
}
try {
return UUID.fromString(value);
} catch (IllegalArgumentException ignored) {
return null;
}
}
/**
* Saves the vote cache to storage.
*/
@@ -234,7 +321,7 @@ public abstract class VoteCacheHandler {
if (!getTimeChangeQueue().isEmpty()) {
for (VoteTimeQueue vote : getTimeChangeQueue()) {
timedVoteCacheTable.insertTimedVote(vote.getName(), vote.getService(), vote.getTime());
timedVoteCacheTable.insertTimedVote(vote.getVoteId(), vote.getName(), vote.getService(), vote.getTime());
}
}
} else {
@@ -286,8 +373,8 @@ public abstract class VoteCacheHandler {
// Load timed votes from MySQL
ArrayList<VoteTimeQueue> timedVotes = new ArrayList<>();
timedVoteCacheTable.getAllVotes().forEach(timedVoteRow -> {
VoteTimeQueue voteTimeQueue = new VoteTimeQueue(timedVoteRow.getPlayerName(), timedVoteRow.getService(),
timedVoteRow.getTime());
VoteTimeQueue voteTimeQueue = new VoteTimeQueue(timedVoteRow.getVoteId(), timedVoteRow.getPlayerName(),
timedVoteRow.getService(), timedVoteRow.getTime());
timedVotes.add(voteTimeQueue);
});
timeChangeQueue.addAll(timedVotes);
@@ -303,8 +390,9 @@ public abstract class VoteCacheHandler {
String name = data.has("Name") ? data.get("Name").asString() : "";
String service = data.has("Service") ? data.get("Service").asString() : "";
long time = data.has("Time") ? data.get("Time").asLong() : 0L;
UUID voteId = readUuid(data, "VoteId");
getTimeChangeQueue().add(new VoteTimeQueue(name, service, time));
getTimeChangeQueue().add(new VoteTimeQueue(voteId, name, service, time));
}
}
@@ -326,7 +414,7 @@ public abstract class VoteCacheHandler {
long time = data.has("Time") ? data.get("Time").asLong() : 0L;
boolean real = data.has("Real") && data.get("Real").asBoolean();
String text = data.has("Text") ? data.get("Text").asString() : "";
String voteId = data.has("VoteId") ? data.get("VoteId").asString() : "";
String voteId = readVoteId(data);
votes.add(new OfflineBungeeVote(voteId, name, uuid, service, time, real, text));
}
@@ -351,7 +439,7 @@ public abstract class VoteCacheHandler {
long time = data.has("Time") ? data.get("Time").asLong() : 0L;
boolean real = data.has("Real") && data.get("Real").asBoolean();
String text = data.has("Text") ? data.get("Text").asString() : "";
String voteId = data.has("VoteId") ? data.get("VoteId").asString() : "";
String voteId = readVoteId(data);
votes.add(new OfflineBungeeVote(voteId, name, uuid, service, time, real, text));
}
@@ -28,6 +28,8 @@ public class VelocityJsonVoteCache extends VelocityJSONFile implements IVoteCach
setPath(voteTimedQueue.getName(), "TimedVoteCache", String.valueOf(num), "Name");
setPath(voteTimedQueue.getService(), "TimedVoteCache", String.valueOf(num), "Service");
setPath(voteTimedQueue.getTime(), "TimedVoteCache", String.valueOf(num), "Time");
setPath(voteTimedQueue.getVoteId() == null ? null : voteTimedQueue.getVoteId().toString(), "TimedVoteCache",
String.valueOf(num), "VoteId");
}
@Override
@@ -38,7 +40,7 @@ public class VelocityJsonVoteCache extends VelocityJSONFile implements IVoteCach
setPath(voteData.getTime(), "VoteCache", server, String.valueOf(num), "Time");
setPath(voteData.isRealVote(), "VoteCache", server, String.valueOf(num), "Real");
setPath(voteData.getText(), "VoteCache", server, String.valueOf(num), "Text");
setPath(voteData.getVoteId() != null ? voteData.getVoteId().toString() : null, "VoteCache", server, String.valueOf(num), "VoteID");
setPath(voteData.getVoteId() != null ? voteData.getVoteId().toString() : null, "VoteCache", server, String.valueOf(num), "VoteId");
}
@Override
@@ -49,7 +51,7 @@ public class VelocityJsonVoteCache extends VelocityJSONFile implements IVoteCach
setPath(voteData.getTime(), "OnlineCache", player, String.valueOf(num), "Time");
setPath(voteData.isRealVote(), "OnlineCache", player, String.valueOf(num), "Real");
setPath(voteData.getText(), "OnlineCache", player, String.valueOf(num), "Text");
setPath(voteData.getVoteId() != null ? voteData.getVoteId().toString() : null, "OnlineCache", player, String.valueOf(num), "VoteID");
setPath(voteData.getVoteId() != null ? voteData.getVoteId().toString() : null, "OnlineCache", player, String.valueOf(num), "VoteId");
}
@Override
@@ -1,8 +1,13 @@
package com.bencodez.votingplugin.timequeue;
import java.util.UUID;
import lombok.Getter;
import lombok.Setter;
/**
* Represents a vote delayed while a proxy time change is active.
*/
public class VoteTimeQueue {
@Getter
@Setter
@@ -13,8 +18,31 @@ public class VoteTimeQueue {
@Getter
@Setter
private long time;
@Getter
@Setter
private UUID voteId;
/**
* Creates a legacy-compatible queued vote without an identifier.
*
* @param name player name
* @param service service site
* @param time vote timestamp
*/
public VoteTimeQueue(String name, String service, long time) {
this(null, name, service, time);
}
/**
* Creates a queued vote with its original identifier.
*
* @param voteId unique vote identifier
* @param name player name
* @param service service site
* @param time vote timestamp
*/
public VoteTimeQueue(UUID voteId, String name, String service, long time) {
this.voteId = voteId;
this.name = name;
this.service = service;
this.time = time;
@@ -0,0 +1,23 @@
package com.bencodez.votingplugin.tests;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
import com.bencodez.votingplugin.listeners.ProxyVoteDelayCheck;
class ProxyVoteDelayCheckTest {
@Test
void acceptsQueuedProxyVoteWhenTheProxyTimestampIsAlreadyStored() {
assertTrue(ProxyVoteDelayCheck.isQueuedVoteAlreadyRecorded(true, 12345L, 12345L));
}
@Test
void doesNotBypassTheDelayForDirectOrStaleProxyVotes() {
assertFalse(ProxyVoteDelayCheck.isQueuedVoteAlreadyRecorded(false, 12345L, 12345L));
assertFalse(ProxyVoteDelayCheck.isQueuedVoteAlreadyRecorded(true, 12344L, 12345L));
assertFalse(ProxyVoteDelayCheck.isQueuedVoteAlreadyRecorded(true, 0L, 0L));
}
}
@@ -0,0 +1,251 @@
package com.bencodez.votingplugin.tests;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.same;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import com.bencodez.votingplugin.proxy.OfflineBungeeVote;
import com.bencodez.votingplugin.proxy.cache.DataNode;
import com.bencodez.votingplugin.proxy.cache.IVoteCache;
import com.bencodez.votingplugin.proxy.cache.VoteCacheHandler;
import com.bencodez.votingplugin.timequeue.VoteTimeQueue;
/**
* Regression tests for proxy vote identity across caches and delayed processing.
*/
public class VoteCacheHandlerVoteIdTest {
private IVoteCache storage;
private VoteCacheHandler handler;
@BeforeEach
public void setUp() {
storage = mock(IVoteCache.class);
when(storage.getServerVotes("server")).thenReturn(Collections.emptyList());
when(storage.getOnlineVotes("player-uuid")).thenReturn(Collections.emptyList());
handler = newHandler(storage);
}
@Test
public void serverCacheRejectsDuplicateVoteId() {
UUID voteId = UUID.randomUUID();
handler.addServerVote("server", vote(voteId, 100L));
handler.addServerVote("server", vote(voteId, 101L));
assertEquals(1, handler.getVotes("server").size());
assertEquals(100L, handler.getVotes("server").get(0).getTime());
verify(storage).addVote("server", 0, handler.getVotes("server").get(0));
}
@Test
public void onlineCacheRejectsDuplicateVoteId() {
UUID voteId = UUID.randomUUID();
handler.addOnlineVote("player-uuid", vote(voteId, 100L));
handler.addOnlineVote("player-uuid", vote(voteId, 101L));
assertEquals(1, handler.getOnlineVotes("player-uuid").size());
verify(storage).addVoteOnline("player-uuid", 0, handler.getOnlineVotes("player-uuid").get(0));
}
@Test
public void concurrentServerDeliveriesReserveVoteIdAtomically() throws Exception {
UUID voteId = UUID.randomUUID();
runConcurrently(() -> handler.addServerVote("server", vote(voteId, 100L)),
() -> handler.addServerVote("server", vote(voteId, 101L)));
assertEquals(1, handler.getVotes("server").size());
verify(storage, times(1)).addVote(org.mockito.ArgumentMatchers.eq("server"),
org.mockito.ArgumentMatchers.anyInt(), org.mockito.ArgumentMatchers.any(OfflineBungeeVote.class));
}
@Test
public void concurrentOnlineDeliveriesReserveVoteIdAtomically() throws Exception {
UUID voteId = UUID.randomUUID();
runConcurrently(() -> handler.addOnlineVote("player-uuid", vote(voteId, 100L)),
() -> handler.addOnlineVote("player-uuid", vote(voteId, 101L)));
assertEquals(1, handler.getOnlineVotes("player-uuid").size());
verify(storage, times(1)).addVoteOnline(org.mockito.ArgumentMatchers.eq("player-uuid"),
org.mockito.ArgumentMatchers.anyInt(), org.mockito.ArgumentMatchers.any(OfflineBungeeVote.class));
}
@Test
public void distinctVoteIdsAreNotCollapsed() {
handler.addServerVote("server", vote(UUID.randomUUID(), 100L));
handler.addServerVote("server", vote(UUID.randomUUID(), 101L));
assertEquals(2, handler.getVotes("server").size());
}
@Test
public void missingVoteIdsRemainLegacyCompatible() {
handler.addServerVote("server", vote(null, 100L));
handler.addServerVote("server", vote(null, 101L));
assertEquals(2, handler.getVotes("server").size());
}
@Test
public void currentVoteIdKeyLoadsFromJsonCache() {
UUID voteId = UUID.randomUUID();
handler = handlerForStoredVote("VoteId", voteId);
handler.load();
assertEquals(voteId, handler.getVotes("server").get(0).getVoteId());
}
@Test
public void legacyVoteIdKeyLoadsFromJsonCache() {
UUID voteId = UUID.randomUUID();
handler = handlerForStoredVote("VoteID", voteId);
handler.load();
assertEquals(voteId, handler.getVotes("server").get(0).getVoteId());
}
@Test
public void timedVoteIdIsPassedToJsonStorage() {
UUID voteId = UUID.randomUUID();
VoteTimeQueue queued = new VoteTimeQueue(voteId, "Player", "Service", 100L);
handler.addTimeVoteToCache(queued);
handler.saveVoteCache();
verify(storage).addTimedVote(eq(0), same(queued));
verify(storage).save();
assertEquals(voteId, handler.getTimeChangeQueue().element().getVoteId());
}
@Test
public void legacyTimedVoteConstructorHasNoId() {
assertNull(new VoteTimeQueue("Player", "Service", 100L).getVoteId());
}
private VoteCacheHandler handlerForStoredVote(String idKey, UUID voteId) {
IVoteCache stored = mock(IVoteCache.class);
DataNode voteNode = mock(DataNode.class);
when(stored.getTimedVoteCache()).thenReturn(Collections.emptyList());
when(stored.getServers()).thenReturn(List.of("server"));
when(stored.getServerVotes("server")).thenReturn(List.of("0"));
when(stored.getServerVotes("server", "0")).thenReturn(voteNode);
when(stored.getPlayers()).thenReturn(Collections.emptyList());
when(voteNode.isObject()).thenReturn(true);
stubString(voteNode, "Name", "Player");
stubString(voteNode, "UUID", "player-uuid");
stubString(voteNode, "Service", "Service");
stubLong(voteNode, "Time", 100L);
stubBoolean(voteNode, "Real", true);
stubString(voteNode, "Text", "totals");
stubString(voteNode, idKey, voteId.toString());
if ("VoteID".equals(idKey)) {
when(voteNode.has("VoteId")).thenReturn(false);
}
return newHandler(stored);
}
private static void stubString(DataNode parent, String key, String value) {
DataNode child = mock(DataNode.class);
when(parent.has(key)).thenReturn(true);
when(parent.get(key)).thenReturn(child);
when(child.asString()).thenReturn(value);
}
private static void stubLong(DataNode parent, String key, long value) {
DataNode child = mock(DataNode.class);
when(parent.has(key)).thenReturn(true);
when(parent.get(key)).thenReturn(child);
when(child.asLong()).thenReturn(value);
}
private static void stubBoolean(DataNode parent, String key, boolean value) {
DataNode child = mock(DataNode.class);
when(parent.has(key)).thenReturn(true);
when(parent.get(key)).thenReturn(child);
when(child.asBoolean()).thenReturn(value);
}
private static void runConcurrently(Runnable first, Runnable second) throws Exception {
ExecutorService executor = Executors.newFixedThreadPool(2);
CountDownLatch ready = new CountDownLatch(2);
CountDownLatch start = new CountDownLatch(1);
try {
Future<?> firstFuture = executor.submit(() -> {
ready.countDown();
await(start);
first.run();
});
Future<?> secondFuture = executor.submit(() -> {
ready.countDown();
await(start);
second.run();
});
ready.await();
start.countDown();
firstFuture.get();
secondFuture.get();
} finally {
executor.shutdownNow();
}
}
private static void await(CountDownLatch latch) {
try {
latch.await();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException(e);
}
}
private static OfflineBungeeVote vote(UUID voteId, long time) {
return new OfflineBungeeVote(voteId, "Player", "player-uuid", "Service", time, true, "totals");
}
private static VoteCacheHandler newHandler(IVoteCache storage) {
return new VoteCacheHandler(null, false, false, null, false, storage) {
@Override
public void logInfo1(String msg) {
}
@Override
public void logSevere1(String msg) {
}
@Override
public void debug1(Exception e) {
}
@Override
public void debug1(Throwable e) {
}
@Override
public void debug1(String msg) {
}
};
}
}
@@ -54,7 +54,6 @@ public class VoteTotalsSnapshotStorageTest {
assertEquals(3, parsed.getVotePartyCurrent());
assertEquals(50, parsed.getVotePartyRequired());
assertEquals(9, parsed.getDateMonthTotal());
assertEquals(voteId, parsed.getVoteUUID());
}
@Test
@@ -106,7 +105,6 @@ public class VoteTotalsSnapshotStorageTest {
assertEquals(11, reparsed.getVotePartyCurrent());
assertEquals(22, reparsed.getVotePartyRequired());
assertEquals(33, reparsed.getDateMonthTotal());
assertEquals(voteId, reparsed.getVoteUUID());
}
@Test
@@ -0,0 +1,42 @@
package com.bencodez.votingplugin.tests;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope;
import com.bencodez.votingplugin.proxy.VotingPluginWire;
import com.bencodez.votingplugin.proxy.VotingPluginWire.Vote;
/**
* Tests proxy vote wire encoding and decoding.
*/
public class VotingPluginWireTest {
@Test
public void voteRoundTripPreservesVoteId() {
UUID voteId = UUID.randomUUID();
JsonEnvelope envelope = VotingPluginWire.vote("Player", UUID.randomUUID().toString(), "Service", 100L,
true, true, "totals", voteId, true, false, 1, 1);
Vote vote = VotingPluginWire.readVote(envelope);
assertEquals(voteId, vote.voteId);
assertEquals("Player", vote.player);
assertEquals("Service", vote.service);
}
@Test
public void voteOnlineRoundTripAllowsMissingVoteId() {
JsonEnvelope envelope = VotingPluginWire.voteOnline("Player", UUID.randomUUID().toString(), "Service",
100L, true, true, "totals", null, true, false, 1, 1);
Vote vote = VotingPluginWire.readVote(envelope);
assertNull(vote.voteId);
}
}
@@ -1,7 +1,6 @@
package com.bencodez.votingplugin.tests.broadcast;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
@@ -18,200 +17,148 @@ import java.util.concurrent.atomic.AtomicReference;
import org.bukkit.Bukkit;
import org.bukkit.Server;
import org.bukkit.command.CommandSender;
import org.bukkit.command.ConsoleCommandSender;
import org.bukkit.entity.Player;
import org.bukkit.scheduler.BukkitScheduler;
import org.bukkit.scheduler.BukkitTask;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import com.bencodez.advancedcore.api.misc.MiscUtils;
import com.bencodez.simpleapi.time.ParsedDuration;
import com.bencodez.votingplugin.VotingPluginMain;
import com.bencodez.votingplugin.broadcast.BroadcastFormat;
import com.bencodez.votingplugin.broadcast.BroadcastHandler;
import com.bencodez.votingplugin.broadcast.BroadcastSettings;
import com.bencodez.votingplugin.broadcast.VoteBroadcastType;
import com.bencodez.votingplugin.user.UserManager;
import com.bencodez.votingplugin.user.VotingPluginUser;
public class BroadcastHandlerTest {
private MockedStatic<Bukkit> bukkitStatic;
private MockedStatic<MiscUtils> miscStatic;
private ConsoleCommandSender console;
@AfterEach
public void cleanup() {
if (bukkitStatic != null) {
bukkitStatic.close();
}
if (miscStatic != null) {
miscStatic.close();
}
}
private static BroadcastSettings settings(VoteBroadcastType type, String duration, int maxSites) {
BroadcastFormat fmt = new BroadcastFormat("BM %player% %site% %reason%",
BroadcastFormat format = new BroadcastFormat("BM %player% %site% %reason%",
"H %player% %sites_count% %sites% %reason%", "L %site%");
return new BroadcastSettings(type, ParsedDuration.parse(duration), maxSites, fmt);
return new BroadcastSettings(type, ParsedDuration.parse(duration), maxSites, format);
}
/**
* BroadcastHandler requires a non-null UserManager and uses console sender.
* Stub both so tests don't NPE and so player is eligible
* (DisableBroadcast=false).
* Stubs Bukkit with no online recipients and a valid console sender.
*
* The handler always writes the final rendered message to the console, so these
* tests can verify broadcast behavior without mocking AdvancedCore's internal
* server message handle.
*/
private void stubEligiblePlayerAndConsole(VotingPluginMain plugin, Player p) {
UserManager um = mock(UserManager.class);
VotingPluginUser user = mock(VotingPluginUser.class);
when(plugin.getVotingPluginUserManager()).thenReturn(um);
when(um.getVotingPluginUser(p)).thenReturn(user);
when(user.getDisableBroadcast()).thenReturn(false);
private void stubBukkitAndConsole() {
Server server = mock(Server.class);
ConsoleCommandSender console = mock(ConsoleCommandSender.class);
console = mock(ConsoleCommandSender.class);
bukkitStatic = mockStatic(Bukkit.class);
bukkitStatic.when(Bukkit::getOnlinePlayers).thenReturn(Collections.emptyList());
bukkitStatic.when(Bukkit::getServer).thenReturn(server);
when(server.getConsoleSender()).thenReturn(console);
}
@Test
public void everyVote_broadcasts_once_per_vote() {
VotingPluginMain plugin = mock(VotingPluginMain.class);
stubBukkitAndConsole();
Player p = mock(Player.class);
bukkitStatic = mockStatic(Bukkit.class);
bukkitStatic.when(Bukkit::getOnlinePlayers).thenReturn(Collections.singletonList(p));
stubEligiblePlayerAndConsole(plugin, p);
MiscUtils misc = mock(MiscUtils.class);
miscStatic = mockStatic(MiscUtils.class);
miscStatic.when(MiscUtils::getInstance).thenReturn(misc);
BroadcastHandler h = new BroadcastHandler(plugin, settings(VoteBroadcastType.EVERY_VOTE, "0", 5),
BroadcastHandler handler = new BroadcastHandler(plugin, settings(VoteBroadcastType.EVERY_VOTE, "0", 5),
ZoneId.systemDefault());
UUID uuid = UUID.randomUUID();
h.broadcastVote(uuid, "Ben", "SiteA", true);
h.broadcastVote(uuid, "Ben", "SiteB", true);
handler.broadcastVote(uuid, "Ben", "SiteA", true);
handler.broadcastVote(uuid, "Ben", "SiteB", true);
verify(misc, times(2)).broadcast(any(String.class), anyList());
verify(console, times(2)).sendMessage(any(String.class));
}
@Test
public void cooldownPerPlayer_suppresses_second_vote_within_window() {
VotingPluginMain plugin = mock(VotingPluginMain.class);
stubBukkitAndConsole();
Player p = mock(Player.class);
bukkitStatic = mockStatic(Bukkit.class);
bukkitStatic.when(Bukkit::getOnlinePlayers).thenReturn(Collections.singletonList(p));
stubEligiblePlayerAndConsole(plugin, p);
MiscUtils misc = mock(MiscUtils.class);
miscStatic = mockStatic(MiscUtils.class);
miscStatic.when(MiscUtils::getInstance).thenReturn(misc);
BroadcastHandler h = new BroadcastHandler(plugin, settings(VoteBroadcastType.COOLDOWN_PER_PLAYER, "10m", 5),
ZoneId.systemDefault());
BroadcastHandler handler = new BroadcastHandler(plugin,
settings(VoteBroadcastType.COOLDOWN_PER_PLAYER, "10m", 5), ZoneId.systemDefault());
UUID uuid = UUID.randomUUID();
h.broadcastVote(uuid, "Ben", "SiteA", true);
h.broadcastVote(uuid, "Ben", "SiteB", true); // within 10m => should not broadcast
handler.broadcastVote(uuid, "Ben", "SiteA", true);
handler.broadcastVote(uuid, "Ben", "SiteB", true);
verify(misc, times(1)).broadcast(any(String.class), anyList());
verify(console, times(1)).sendMessage(any(String.class));
}
@Test
public void firstVoteOfDay_only_first_broadcasts_for_that_day() {
VotingPluginMain plugin = mock(VotingPluginMain.class);
stubBukkitAndConsole();
Player p = mock(Player.class);
bukkitStatic = mockStatic(Bukkit.class);
bukkitStatic.when(Bukkit::getOnlinePlayers).thenReturn(Collections.singletonList(p));
stubEligiblePlayerAndConsole(plugin, p);
MiscUtils misc = mock(MiscUtils.class);
miscStatic = mockStatic(MiscUtils.class);
miscStatic.when(MiscUtils::getInstance).thenReturn(misc);
BroadcastHandler h = new BroadcastHandler(plugin, settings(VoteBroadcastType.FIRST_VOTE_OF_DAY, "0", 5),
BroadcastHandler handler = new BroadcastHandler(plugin, settings(VoteBroadcastType.FIRST_VOTE_OF_DAY, "0", 5),
ZoneId.systemDefault());
UUID uuid = UUID.randomUUID();
h.broadcastVote(uuid, "Ben", "SiteA", true);
h.broadcastVote(uuid, "Ben", "SiteB", true);
handler.broadcastVote(uuid, "Ben", "SiteA", true);
handler.broadcastVote(uuid, "Ben", "SiteB", true);
verify(misc, times(1)).broadcast(any(String.class), anyList());
verify(console, times(1)).sendMessage(any(String.class));
}
@Test
public void batchWindowPerPlayer_flushes_once_and_lists_sites() {
VotingPluginMain plugin = mock(VotingPluginMain.class);
Player p = mock(Player.class);
BukkitScheduler scheduler = mock(BukkitScheduler.class);
BukkitTask task = mock(BukkitTask.class);
bukkitStatic = mockStatic(Bukkit.class);
bukkitStatic.when(Bukkit::getOnlinePlayers).thenReturn(Collections.singletonList(p));
stubBukkitAndConsole();
bukkitStatic.when(Bukkit::getScheduler).thenReturn(scheduler);
stubEligiblePlayerAndConsole(plugin, p);
AtomicReference<Runnable> scheduled = new AtomicReference<>();
when(scheduler.runTaskLater(eq(plugin), any(Runnable.class), anyLong())).thenAnswer(inv -> {
scheduled.set(inv.getArgument(1));
AtomicReference<Runnable> scheduled = new AtomicReference<Runnable>();
when(scheduler.runTaskLater(eq(plugin), any(Runnable.class), anyLong())).thenAnswer(invocation -> {
scheduled.set(invocation.getArgument(1));
return task;
});
MiscUtils misc = mock(MiscUtils.class);
miscStatic = mockStatic(MiscUtils.class);
miscStatic.when(MiscUtils::getInstance).thenReturn(misc);
BroadcastSettings s = new BroadcastSettings(VoteBroadcastType.BATCH_WINDOW_PER_PLAYER,
BroadcastSettings broadcastSettings = new BroadcastSettings(VoteBroadcastType.BATCH_WINDOW_PER_PLAYER,
ParsedDuration.parse("1s"), 10, new BroadcastFormat("SINGLE %player% %site%",
"HEADER %player% %sites_count% %sites% %reason%", " - %site%"));
BroadcastHandler h = new BroadcastHandler(plugin, s, ZoneId.systemDefault());
BroadcastHandler handler = new BroadcastHandler(plugin, broadcastSettings, ZoneId.systemDefault());
UUID uuid = UUID.randomUUID();
h.broadcastVote(uuid, "Ben", "SiteB", true);
h.broadcastVote(uuid, "Ben", "SiteA", true);
handler.broadcastVote(uuid, "Ben", "SiteB", true);
handler.broadcastVote(uuid, "Ben", "SiteA", true);
// run the scheduled flush after both votes
scheduled.get().run();
// 2 sites => render produces 3 lines (header + 2 list lines), each broadcast
// separately
verify(misc, times(3)).broadcast(any(String.class), anyList());
// header should contain both sites and count=2
verify(misc).broadcast(argThat(msg -> msg.contains("HEADER") && msg.contains("Ben") && msg.contains("2")
&& msg.contains("SiteA") && msg.contains("SiteB") && msg.contains("batch")), anyList());
verify(console, times(3)).sendMessage(any(String.class));
verify(console).sendMessage(org.mockito.ArgumentMatchers.<String>argThat(
message -> message.contains("HEADER") && message.contains("Ben") && message.contains("2")
&& message.contains("SiteA") && message.contains("SiteB") && message.contains("batch")));
}
@Test
public void maxSitesListed_truncates_list_in_multi_broadcast() {
VotingPluginMain plugin = mock(VotingPluginMain.class);
stubBukkitAndConsole();
Player p = mock(Player.class);
bukkitStatic = mockStatic(Bukkit.class);
bukkitStatic.when(Bukkit::getOnlinePlayers).thenReturn(Collections.singletonList(p));
stubEligiblePlayerAndConsole(plugin, p);
MiscUtils misc = mock(MiscUtils.class);
miscStatic = mockStatic(MiscUtils.class);
miscStatic.when(MiscUtils::getInstance).thenReturn(misc);
BroadcastSettings s = new BroadcastSettings(VoteBroadcastType.EVERY_VOTE, ParsedDuration.parse("0"), 1,
BroadcastSettings broadcastSettings = new BroadcastSettings(VoteBroadcastType.EVERY_VOTE,
ParsedDuration.parse("0"), 1,
new BroadcastFormat("SINGLE %player% %site%", "H %player% %sites_count% %sites%", "L %site%"));
BroadcastHandler h = new BroadcastHandler(plugin, s, ZoneId.systemDefault());
BroadcastHandler handler = new BroadcastHandler(plugin, broadcastSettings, ZoneId.systemDefault());
h.broadcastVote(UUID.randomUUID(), "Ben", "SiteA", true);
handler.broadcastVote(UUID.randomUUID(), "Ben", "SiteA", true);
verify(misc, times(1)).broadcast(any(String.class), anyList());
verify(console, times(1)).sendMessage(any(String.class));
}
}
}