Files
VotingPlugin/VotingPlugin/src/main/java/com/bencodez/votingplugin/broadcast/BroadcastHandler.java
T

551 lines
16 KiB
Java

package com.bencodez.votingplugin.broadcast;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
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.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.proxy.VoteTotalsSnapshot;
import com.bencodez.votingplugin.user.VotingPluginUser;
/**
* Core broadcast logic.
*
* Decides when to broadcast and builds lines and messages. Uses the existing
* eligible online players behavior and respects disabled broadcasts.
*
* Backend servers only.
*/
public final class BroadcastHandler {
private final VotingPluginMain plugin;
/**
* Only used for day-boundary logic for first vote of day broadcasts.
*/
private final ZoneId zoneId;
private volatile BroadcastSettings settings;
private final ConcurrentHashMap<UUID, Instant> lastBroadcastAt = new ConcurrentHashMap<UUID, Instant>();
private final ConcurrentHashMap<UUID, LinkedHashSet<String>> pendingSites = new ConcurrentHashMap<UUID, LinkedHashSet<String>>();
private final ConcurrentHashMap<UUID, VoteTotalsSnapshot> pendingTotals = new ConcurrentHashMap<UUID, VoteTotalsSnapshot>();
private final ConcurrentHashMap<UUID, BukkitTask> pendingFlush = new ConcurrentHashMap<UUID, BukkitTask>();
private final ConcurrentHashMap<UUID, LocalDate> firstVoteDay = new ConcurrentHashMap<UUID, LocalDate>();
private static final class IntervalRecord {
private String playerName;
private final LinkedHashSet<String> sites = new LinkedHashSet<String>();
private IntervalRecord(String playerName) {
this.playerName = playerName;
}
}
private final Object intervalLock = new Object();
private Map<UUID, IntervalRecord> intervalRecords = new HashMap<UUID, IntervalRecord>();
private volatile BukkitTask intervalTask;
/**
* Constructs a new broadcast handler.
*
* @param plugin the main plugin instance
* @param settings the broadcast settings
* @param zoneId the time zone ID, or null to use the system default
*/
public BroadcastHandler(VotingPluginMain plugin, BroadcastSettings settings, ZoneId zoneId) {
this.plugin = plugin;
this.settings = settings;
this.zoneId = zoneId == null ? ZoneId.systemDefault() : zoneId;
rescheduleIntervalIfNeeded();
}
/**
* Updates the broadcast settings and reschedules the interval task when
* required.
*
* @param settings the new broadcast settings
*/
public void setSettings(BroadcastSettings settings) {
this.settings = settings;
rescheduleIntervalIfNeeded();
}
/**
* Handles a received vote.
*
* @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) {
broadcastVote(uuid, playerName, siteName, wasOnline, null);
}
/**
* Handles a received vote with an optional proxy-projected totals snapshot.
*
* @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
* @param totals projected totals for this broadcast, or null to use stored values
*/
public void broadcastVote(UUID uuid, String playerName, String siteName, boolean wasOnline,
VoteTotalsSnapshot totals) {
BroadcastSettings currentSettings = settings;
if (currentSettings == null || currentSettings.isDisabled()) {
return;
}
String name = playerName == null || playerName.isEmpty() ? resolveName(uuid) : playerName;
if (currentSettings.getType() == VoteBroadcastType.INTERVAL_SUMMARY_GLOBAL) {
recordInterval(uuid, name, siteName);
}
VoteBroadcastType type = currentSettings.getType();
if (type == VoteBroadcastType.EVERY_VOTE_ONLINE_ONLY) {
if (!wasOnline) {
return;
}
broadcastNow(uuid, name, single(siteName), "vote_online_only", null, totals);
return;
}
if (type == VoteBroadcastType.EVERY_VOTE) {
broadcastNow(uuid, name, single(siteName), "vote", null, totals);
} else if (type == VoteBroadcastType.COOLDOWN_PER_PLAYER) {
if (checkAndMarkCooldown(uuid, currentSettings.getDuration())) {
broadcastNow(uuid, name, single(siteName), "cooldown", null, totals);
}
} else if (type == VoteBroadcastType.BATCH_WINDOW_PER_PLAYER) {
bufferBatch(uuid, name, siteName, currentSettings.getDuration(), totals);
} else if (type == VoteBroadcastType.FIRST_VOTE_OF_DAY) {
if (markFirstVoteOfDay(uuid)) {
broadcastNow(uuid, name, single(siteName), "first_day", null, totals);
}
} else if (type == VoteBroadcastType.INTERVAL_SUMMARY_GLOBAL) {
// Handled by the scheduled interval task.
}
}
/**
* 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(UUID votedPlayerUuid, String playerName, List<String> sites, String reason,
Map<String, String> extraContext, VoteTotalsSnapshot totals) {
BroadcastSettings currentSettings = settings;
if (currentSettings == null || currentSettings.isDisabled()) {
return;
}
List<String> cleaned = applyMaxSites(sites, currentSettings);
cleaned.sort(new Comparator<String>() {
@Override
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 = currentSettings.getFormat().render(playerName, cleaned, reason);
} else {
lines = currentSettings.getFormat().render(playerName, cleaned, reason, extraContext);
}
OfflinePlayer votedPlayer = votedPlayerUuid == null ? null : Bukkit.getOfflinePlayer(votedPlayerUuid);
for (String line : lines) {
String totalsLine = totals == null ? line
: totals.applyBroadcastPlaceholders(line,
plugin.getConfigFile().isUseMonthDateTotalsAsPrimaryTotal());
String parsedLine = PlaceholderUtils.replacePlaceHolders(votedPlayer, totalsLine);
broadcastToEligiblePlayers(parsedLine);
}
}
/**
* 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 broadcastMessage) {
if (broadcastMessage == null || broadcastMessage.isEmpty()) {
return;
}
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));
}
}
}
Bukkit.getServer().getConsoleSender().sendMessage(MessageAPI.colorize(broadcastMessage));
}
/**
* 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;
}
Instant now = Instant.now();
Instant last = lastBroadcastAt.get(uuid);
if (last == null) {
lastBroadcastAt.put(uuid, now);
return true;
}
Instant next = cooldown.addTo(last);
if (!now.isBefore(next)) {
lastBroadcastAt.put(uuid, now);
return true;
}
return false;
}
/**
* 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,
VoteTotalsSnapshot totals) {
if (totals != null) {
pendingTotals.put(uuid, totals);
} else {
pendingTotals.remove(uuid);
}
if (siteName != null && !siteName.isEmpty()) {
LinkedHashSet<String> sites = pendingSites.get(uuid);
if (sites == null) {
LinkedHashSet<String> created = new LinkedHashSet<String>();
sites = pendingSites.putIfAbsent(uuid, created);
if (sites == null) {
sites = created;
}
}
synchronized (sites) {
sites.add(siteName);
}
}
if (!pendingFlush.containsKey(uuid)) {
BukkitTask task = scheduleBatchFlush(uuid, playerName, window);
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 delayMillis = window == null ? 1L : window.delayMillisFromNow();
long ticks = Math.max(1L, delayMillis / 50L);
return Bukkit.getScheduler().runTaskLater(plugin, new Runnable() {
@Override
public void run() {
try {
flushBatch(uuid, playerName);
} finally {
pendingFlush.remove(uuid);
}
}
}, 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> sites = pendingSites.remove(uuid);
VoteTotalsSnapshot totals = pendingTotals.remove(uuid);
if (sites == null || sites.isEmpty()) {
return;
}
List<String> siteList;
synchronized (sites) {
siteList = new ArrayList<String>(sites);
}
broadcastNow(uuid, playerName, siteList, "batch", null, totals);
}
/**
* 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);
if (last == null) {
return true;
}
if (!last.equals(today)) {
firstVoteDay.put(uuid, today);
return true;
}
return false;
}
/**
* Reschedules the global interval summary task when required.
*/
private void rescheduleIntervalIfNeeded() {
if (intervalTask != null) {
intervalTask.cancel();
intervalTask = null;
}
BroadcastSettings currentSettings = settings;
if (currentSettings == null
|| currentSettings.getType() != VoteBroadcastType.INTERVAL_SUMMARY_GLOBAL) {
return;
}
long delayMillis = currentSettings.getDuration() == null
? 1L
: currentSettings.getDuration().delayMillisFromNow();
long ticks = Math.max(1L, delayMillis / 50L);
intervalTask = Bukkit.getScheduler().runTaskTimer(plugin, new Runnable() {
@Override
public void run() {
broadcastIntervalSummary();
}
}, 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 playerName, String siteName) {
if (uuid == null || siteName == null || siteName.isEmpty()) {
return;
}
// The name and its sites are added under the same lock used to rotate intervals.
synchronized (intervalLock) {
IntervalRecord record = intervalRecords.computeIfAbsent(uuid, ignored -> new IntervalRecord(playerName));
if (playerName != null && !playerName.isEmpty()) {
record.playerName = playerName;
}
record.sites.add(siteName);
}
}
/**
* Broadcasts the global interval summary.
*
* The interval summary has no single voted player, so PlaceholderAPI
* placeholders are left unchanged.
*/
private void broadcastIntervalSummary() {
BroadcastSettings currentSettings = settings;
if (currentSettings == null || currentSettings.isDisabled()
|| currentSettings.getType() != VoteBroadcastType.INTERVAL_SUMMARY_GLOBAL) {
return;
}
Map<UUID, IntervalRecord> snapshot;
synchronized (intervalLock) {
snapshot = intervalRecords;
intervalRecords = new HashMap<UUID, IntervalRecord>();
}
if (snapshot.isEmpty()) {
return;
}
List<String> entries = new ArrayList<String>();
List<String> players = new ArrayList<String>();
LinkedHashSet<String> uniqueSites = new LinkedHashSet<String>();
for (Map.Entry<UUID, IntervalRecord> entry : snapshot.entrySet()) {
IntervalRecord record = entry.getValue();
LinkedHashSet<String> siteSet = record.sites;
if (siteSet == null || siteSet.isEmpty()) {
continue;
}
String name = record.playerName;
if (name == null || name.isEmpty()) {
name = "Player";
}
players.add(name);
int siteCount = siteSet.size();
for (String site : siteSet) {
if (site != null && !site.isEmpty()) {
uniqueSites.add(site);
}
}
entries.add(name + " (" + siteCount + ")");
}
if (entries.isEmpty()) {
return;
}
String playersCsv = join(players);
String sitesCsv = join(new ArrayList<String>(uniqueSites));
ConcurrentHashMap<String, String> context = new ConcurrentHashMap<String, String>();
context.put("players", playersCsv);
context.put("numberofplayers", String.valueOf(players.size()));
context.put("sites", sitesCsv);
context.put("numberofsites", String.valueOf(uniqueSites.size()));
broadcastNow(null, "Server", entries, "interval", context, null);
}
/**
* Creates a single-value site list.
*
* @param site the site name
* @return the site list
*/
private List<String> single(String site) {
List<String> sites = new ArrayList<String>();
if (site != null && !site.isEmpty()) {
sites.add(site);
}
return sites;
}
/**
* 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 maximum = currentSettings.getMaxSitesListed();
if (maximum <= 0 || sites.size() <= maximum) {
return new ArrayList<String>(sites);
}
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 offlinePlayer = Bukkit.getOfflinePlayer(uuid);
String name = offlinePlayer == null ? null : offlinePlayer.getName();
return name == null || name.isEmpty() ? "Player" : name;
} catch (Exception exception) {
return "Player";
}
}
/**
* 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 value : values) {
if (value != null && !value.isEmpty()) {
joiner.add(value);
}
}
return joiner.toString();
}
}