C#:
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using Facepunch.Extend;
using Newtonsoft.Json;
using Oxide.Core;
using Oxide.Core.Plugins;
using Oxide.Core.Configuration;
using Oxide.Core.Libraries;
using UnityEngine;
using UnityEngine.Networking;
namespace Oxide.Plugins {
[Info("Easy Vote Extended", "dFxPhoeniX&TimRS", "3.2.9")]
[Description("The best Rust server voting system")]
public class EasyVoteExtended : RustPlugin {
private readonly HashSet<string> pendingClaims = new HashSet<string>();
private readonly HashSet<string> pendingStatusChecks = new HashSet<string>();
private readonly Dictionary<string, string> statusRequestTokens = new Dictionary<string, string>();
private readonly HashSet<ulong> scheduledVoteChecks = new HashSet<ulong>();
private readonly HashSet<string> reportedConfigurationWarnings = new HashSet<string>();
private readonly Queue<Action> claimRequestQueue = new Queue<Action>();
private readonly Queue<Action> voteRequestQueue = new Queue<Action>();
private readonly Dictionary<string, float> commandCooldowns = new Dictionary<string, float>();
private readonly HashSet<string> scheduledClaimRetries = new HashSet<string>();
private Dictionary<string, List<string>> pendingRewardCommands = new Dictionary<string, List<string>>();
private Dictionary<string, PendingClaimTransaction> pendingClaimTransactions = new Dictionary<string, PendingClaimTransaction>();
private const int CurrentConfigurationVersion = 1;
private const int DefaultAutomaticVoteCheckInterval = 300;
private const int DefaultVoteFollowUpCheckDelay = 60;
private const float VoteRequestSpacing = 0.2f;
private const float ManualCommandCooldown = 10f;
private const int VoteApiRequestTimeout = 15000;
private const int MaximumClaimAttempts = 6;
private const string PendingRewardsDataFileName = "EasyVoteExtended_PendingRewards";
private const string PendingClaimsDataFileName = "EasyVoteExtended_PendingClaims";
private const string PendingRewardTransactionPrefix = "__EVE_TX__:";
private const string ClaimStateCreated = "Created";
private const string ClaimStateSent = "Sent";
private const string ClaimStateUncertain = "Uncertain";
private const string ClaimStateConfirmed = "Confirmed";
private const string ClaimStateManualReview = "ManualReview";
private class PendingClaimTransaction {
public string TransactionId;
public ulong PlayerId;
public string PlayerName;
public string ServerName;
public string Site;
public bool NotifyPlayer;
public int Attempts;
public string State;
public bool HadAmbiguousFailure;
public int TargetVoteCount;
}
////////////////////////////////////////////////////////////
// Oxide Hooks
////////////////////////////////////////////////////////////
private void Init() {
// LoadConfig() normally validates the configuration before Init().
// Keep this fallback only for unexpected load-order scenarios.
if (_config == null) {
_config = Config.ReadObject<PluginConfig>();
EnsureConfigDefaults();
}
LoadMessages();
LoadPendingRewardsData();
LoadPendingClaimsData();
}
private void OnServerInitialized() {
ConsoleLog("Easy Vote Extended has been initialized...");
StartRequestQueueProcessor();
StartAutomaticVoteChecks();
RecoverPendingClaimTransactions();
timer.Once(2f, () => {
foreach (BasePlayer player in BasePlayer.activePlayerList.ToArray()) {
if (player == null || !player.IsConnected) {
continue;
}
CheckIfPlayerDataExists(player);
DeliverPendingRewards(player);
CheckVotingStatus(player, false);
}
}
);
}
private void Unload() {
pendingClaims.Clear();
pendingStatusChecks.Clear();
statusRequestTokens.Clear();
scheduledVoteChecks.Clear();
reportedConfigurationWarnings.Clear();
claimRequestQueue.Clear();
voteRequestQueue.Clear();
commandCooldowns.Clear();
scheduledClaimRetries.Clear();
SavePendingRewardsData();
SavePendingClaimsData();
}
private void OnNewSave(string filename) {
_Debug("------------------------------");
_Debug("Method: OnNewSave");
ConsoleLog("New map data detected!");
if (_config.PluginSettings[ConfigDefaultKeys.ClearRewardsOnWipe].ToBool()) {
_Debug("Wiping all votes from data file");
ResetAllVoteData();
}
}
private void OnPlayerConnected(BasePlayer player) {
_Debug("------------------------------");
_Debug("Method: OnPlayerConnected");
_Debug($"Player: {player.displayName}/{player.UserIDString}");
CheckIfPlayerDataExists(player);
ulong connectedPlayerId = player.userID;
timer.Once(2f, () => {
BasePlayer connectedPlayer = BasePlayer.FindByID(connectedPlayerId);
if (connectedPlayer != null && connectedPlayer.IsConnected) {
DeliverPendingRewards(connectedPlayer);
}
}
);
bool checkOnSleepEnded = _config.NotificationSettings[ConfigDefaultKeys.OnPlayerSleepEnded].ToBool();
bool notifyOnConnected = _config.NotificationSettings[ConfigDefaultKeys.OnPlayerConnected].ToBool();
if (!checkOnSleepEnded) {
CheckVotingStatus(player, notifyOnConnected);
}
}
private void OnPlayerDisconnected(BasePlayer player, string reason) {
if (player == null) {
return;
}
commandCooldowns.Remove($"{player.UserIDString}:vote");
commandCooldowns.Remove($"{player.UserIDString}:claim");
scheduledVoteChecks.Remove(player.userID);
}
private void OnPlayerSleepEnded(BasePlayer player) {
_Debug("------------------------------");
_Debug("Method: OnPlayerSleepEnded");
_Debug($"Player: {player.displayName}/{player.UserIDString}");
DeliverPendingRewards(player);
if (_config.NotificationSettings[ConfigDefaultKeys.OnPlayerSleepEnded].ToBool()) {
CheckVotingStatus(player);
}
}
////////////////////////////////////////////////////////////
// General Methods
////////////////////////////////////////////////////////////
private void HandleClaimWebRequestCallback(int code, string response, string pendingClaimKey, string transactionId, string requestToken) {
pendingClaims.Remove(requestToken);
PendingClaimTransaction transaction;
if (!TryGetActiveClaimTransaction(pendingClaimKey, transactionId, out transaction)) {
_Debug($"Ignoring a stale claim callback for transaction {transactionId}.");
return;
}
string playerSteamId = transaction.PlayerId.ToString();
BasePlayer player = FindPlayer(transaction.PlayerId);
string resolvedPlayerName = GetResolvedPlayerName(player, transaction.PlayerName, playerSteamId);
if (code != 200) {
transaction.State = ClaimStateUncertain;
transaction.HadAmbiguousFailure = true;
SavePendingClaimsData();
ConsoleError($"An error occurred while trying to claim the vote reward of the player {resolvedPlayerName}:{playerSteamId}");
ConsoleWarn($"HTTP Code: {code} | Response: {response} | Server Name: {transaction.ServerName}");
ConsoleWarn("The claim transaction was preserved and will be retried automatically.");
ScheduleClaimRetry(pendingClaimKey);
return;
}
response = response?.Trim();
_Debug("------------------------------");
_Debug("Method: HandleClaimWebRequestCallback");
_Debug($"Site: {transaction.Site}");
_Debug($"Code: {code}");
_Debug($"Response: {response}");
_Debug($"ServerName: {transaction.ServerName}");
_Debug($"Player Name: {resolvedPlayerName}");
_Debug($"Player SteamID: {playerSteamId}");
_Debug("Web Request Type: Claim");
if (_config.DebugSettings[ConfigDefaultKeys.VerboseDebugEnabled].ToBool()) {
_Debug($"Verbose Debug Enabled, Setting Response Code to: {_config.DebugSettings[ConfigDefaultKeys.ClaimAPIRepsonseCode]}");
response = _config.DebugSettings[ConfigDefaultKeys.ClaimAPIRepsonseCode];
}
if (response == "1") {
ConfirmClaimTransaction(pendingClaimKey, transaction);
return;
}
if (response == "2") {
if (transaction.HadAmbiguousFailure) {
// A previous request was sent but its response was lost or invalid.
// In the normal one-plugin workflow, "already claimed" now confirms
// that the preserved request reached the tracker.
ConfirmClaimTransaction(pendingClaimKey, transaction);
return;
}
RemovePendingClaimTransaction(pendingClaimKey);
if (transaction.NotifyPlayer && player != null && player.IsConnected) {
player.ChatMessage(_lang("AlreadyVoted", player.UserIDString, _config.PluginSettings[ConfigDefaultKeys.Prefix], transaction.Site));
}
return;
}
if (response == "0") {
RemovePendingClaimTransaction(pendingClaimKey);
if (transaction.NotifyPlayer && player != null && player.IsConnected) {
player.ChatMessage(_lang("ClaimStatus", player.UserIDString, _config.PluginSettings[ConfigDefaultKeys.Prefix], transaction.Site, transaction.ServerName));
}
return;
}
transaction.State = ClaimStateUncertain;
transaction.HadAmbiguousFailure = true;
SavePendingClaimsData();
ConsoleWarn($"Unexpected claim API response from {transaction.Site} for {resolvedPlayerName}/{playerSteamId} on {transaction.ServerName}: '{response ?? "null"}'. Expected 0, 1, or 2. The claim transaction will be retried.");
ScheduleClaimRetry(pendingClaimKey);
}
private bool TryGetActiveClaimTransaction(string pendingClaimKey, string transactionId, out PendingClaimTransaction transaction) {
transaction = null;
return !string.IsNullOrEmpty(transactionId) && pendingClaimTransactions.TryGetValue(pendingClaimKey, out transaction) && transaction != null && string.Equals(transaction.TransactionId, transactionId, StringComparison.Ordinal);
}
private void ConfirmClaimTransaction(string pendingClaimKey, PendingClaimTransaction transaction) {
if (transaction == null || !pendingClaimTransactions.ContainsKey(pendingClaimKey) || !ReferenceEquals(pendingClaimTransactions[pendingClaimKey], transaction)) {
return;
}
if (transaction.TargetVoteCount <= 0) {
transaction.TargetVoteCount = GetStoredVoteCount(transaction.PlayerId.ToString()) + 1;
}
transaction.State = ClaimStateConfirmed;
SavePendingClaimsData();
FinalizeConfirmedClaimTransaction(pendingClaimKey, transaction.TransactionId);
}
private void FinalizeConfirmedClaimTransaction(string pendingClaimKey, string transactionId) {
PendingClaimTransaction transaction;
if (!TryGetActiveClaimTransaction(pendingClaimKey, transactionId, out transaction) || !string.Equals(transaction.State, ClaimStateConfirmed, StringComparison.Ordinal)) {
return;
}
string playerSteamId = transaction.PlayerId.ToString();
BasePlayer player = FindPlayer(transaction.PlayerId);
string resolvedPlayerName = GetResolvedPlayerName(player, transaction.PlayerName, playerSteamId);
int targetVoteCount = Math.Max(1, transaction.TargetVoteCount);
int currentVoteCount = GetStoredVoteCount(playerSteamId);
if (currentVoteCount < targetVoteCount) {
DataFile[playerSteamId] = targetVoteCount;
SaveDataFile(DataFile);
}
bool cumulativeRewards = _config.PluginSettings[ConfigDefaultKeys.RewardIsCumulative].ToBool();
List<string> rewardCommands = BuildRewardCommands(transaction.PlayerId, resolvedPlayerName, targetVoteCount, cumulativeRewards);
bool rewardConfigured = rewardCommands.Count > 0;
if (rewardConfigured && !QueuePendingRewardTransaction(playerSteamId, transaction.TransactionId, rewardCommands)) {
ConsoleError($"Failed to persist reward commands for claim transaction {transaction.TransactionId}. Local completion will be retried.");
ScheduleClaimRetry(pendingClaimKey);
return;
}
// Removing the claim transaction only after the vote count and reward
// queue are persisted makes local completion idempotent after reloads.
RemovePendingClaimTransaction(pendingClaimKey);
bool rewardDeliveredImmediately = rewardConfigured && player != null && player.IsConnected && !player.IsSleeping();
if (rewardDeliveredImmediately) {
DeliverPendingRewards(player, false);
}
string displayedVoteCount = GetStoredVoteCount(playerSteamId).ToString();
if (player != null && player.IsConnected) {
string messageKey = rewardConfigured ? (rewardDeliveredImmediately ? "ThankYou" : "ThankYouPending") : "ThankYouNoReward";
player.ChatMessage(_lang(messageKey, player.UserIDString, _config.PluginSettings[ConfigDefaultKeys.Prefix], displayedVoteCount, transaction.Site));
}
if (_config.Discord[ConfigDefaultKeys.DiscordEnabled].ToBool()) {
string discordMessageKey = rewardConfigured ? (rewardDeliveredImmediately ? "DiscordWebhookMessage" : "DiscordWebhookMessagePending") : "DiscordWebhookMessageNoReward";
ServerMgr.Instance.StartCoroutine(DiscordSendMessage(_lang(discordMessageKey, null, resolvedPlayerName, transaction.ServerName, transaction.Site)));
}
if (_config.NotificationSettings[ConfigDefaultKeys.GlobalChatAnnouncements].ToBool()) {
string globalMessageKey = rewardConfigured ? (rewardDeliveredImmediately ? "GlobalChatAnnouncements" : "GlobalChatAnnouncementsPending") : "GlobalChatAnnouncementsNoReward";
foreach (BasePlayer recipient in BasePlayer.activePlayerList) {
if (recipient == null || !recipient.IsConnected) {
continue;
}
recipient.ChatMessage(_lang(globalMessageKey, recipient.UserIDString, _config.PluginSettings[ConfigDefaultKeys.Prefix], resolvedPlayerName, displayedVoteCount));
}
}
}
private void HandleStatusWebRequestCallback(int code, string response, ulong playerId, string playerName, string statusUrl, string serverName, string site, bool notifyPlayer, string pendingStatusKey, string statusRequestToken) {
if (!TryConsumeStatusRequest(pendingStatusKey, statusRequestToken)) {
_Debug($"Ignoring a stale status callback for {playerName}/{playerId} on {site} ({serverName}).");
return;
}
string playerSteamId = playerId.ToString();
BasePlayer player = FindPlayer(playerId);
string resolvedPlayerName = GetResolvedPlayerName(player, playerName, playerSteamId);
if (code != 200) {
ConsoleError($"An error occurred while trying to check the vote status of the player {resolvedPlayerName}:{playerSteamId}");
ConsoleWarn($"URL: {MaskApiUrl(statusUrl)}");
ConsoleWarn($"HTTP Code: {code} | Response: {response} | Server Name: {serverName}");
ConsoleWarn("This error could be due to a malformed or incorrect server token, id, or player id / username issue. Most likely its due to your server key being incorrect. Check that your server key is correct.");
return;
}
response = response?.Trim();
_Debug("------------------------------");
_Debug("Method: HandleStatusWebRequestCallback");
_Debug($"Site: {site}");
_Debug($"Code: {code}");
_Debug($"Response: {response}");
_Debug($"URL: {MaskApiUrl(statusUrl)}");
_Debug($"ServerName: {serverName}");
_Debug($"Player Name: {resolvedPlayerName}");
_Debug($"Player SteamID: {playerSteamId}");
_Debug("Web Request Type: Status/Check");
if (_config.DebugSettings[ConfigDefaultKeys.VerboseDebugEnabled].ToBool()) {
_Debug($"Verbose Debug Enabled, Setting Response Code to: {_config.DebugSettings[ConfigDefaultKeys.CheckAPIResponseCode]}");
response = _config.DebugSettings[ConfigDefaultKeys.CheckAPIResponseCode];
}
string pendingClaimKey = GetPendingClaimKey(playerId, serverName, site);
PendingClaimTransaction existingTransaction;
pendingClaimTransactions.TryGetValue(pendingClaimKey, out existingTransaction);
if (response == "1") {
EnqueueClaimRequest(playerId, resolvedPlayerName, serverName, site, notifyPlayer);
return;
}
if (response == "0") {
if (existingTransaction != null) {
RemovePendingClaimTransaction(pendingClaimKey);
}
if (notifyPlayer && player != null && player.IsConnected) {
player.ChatMessage(_lang("NoRewards", player.UserIDString, _config.PluginSettings[ConfigDefaultKeys.Prefix], serverName, site));
}
return;
}
if (response == "2") {
if (existingTransaction != null && existingTransaction.HadAmbiguousFailure) {
ConfirmClaimTransaction(pendingClaimKey, existingTransaction);
return;
}
if (existingTransaction != null) {
RemovePendingClaimTransaction(pendingClaimKey);
}
if (notifyPlayer && player != null && player.IsConnected) {
player.ChatMessage(_lang("AlreadyVoted", player.UserIDString, _config.PluginSettings[ConfigDefaultKeys.Prefix], site));
}
return;
}
ConsoleWarn($"Unexpected status API response from {site} for {resolvedPlayerName}/{playerSteamId} on {serverName}: '{response ?? "null"}'. Expected 0, 1, or 2.");
}
private bool IsActiveStatusRequest(string pendingStatusKey, string statusRequestToken) {
string activeToken;
return !string.IsNullOrEmpty(statusRequestToken) && statusRequestTokens.TryGetValue(pendingStatusKey, out activeToken) && string.Equals(activeToken, statusRequestToken, StringComparison.Ordinal);
}
private bool TryConsumeStatusRequest(string pendingStatusKey, string statusRequestToken) {
if (!IsActiveStatusRequest(pendingStatusKey, statusRequestToken)) {
return false;
}
statusRequestTokens.Remove(pendingStatusKey);
pendingStatusChecks.Remove(pendingStatusKey);
return true;
}
private void CancelStatusRequest(string pendingStatusKey, string statusRequestToken) {
if (!IsActiveStatusRequest(pendingStatusKey, statusRequestToken)) {
return;
}
statusRequestTokens.Remove(pendingStatusKey);
pendingStatusChecks.Remove(pendingStatusKey);
}
private void CancelStatusRequestsForPlayer(string steamId) {
if (string.IsNullOrEmpty(steamId)) {
return;
}
foreach (string pendingStatusKey in statusRequestTokens.Keys .Where(key => key.StartsWith(steamId + ":", StringComparison.Ordinal)) .ToList()) {
statusRequestTokens.Remove(pendingStatusKey);
pendingStatusChecks.Remove(pendingStatusKey);
}
}
private void EnqueueClaimRequest(ulong playerId, string playerName, string serverName, string site, bool notifyPlayer) {
string pendingClaimKey = GetPendingClaimKey(playerId, serverName, site);
PendingClaimTransaction transaction;
if (!pendingClaimTransactions.TryGetValue(pendingClaimKey, out transaction) || transaction == null) {
transaction = new PendingClaimTransaction {
TransactionId = Guid.NewGuid().ToString("N"), PlayerId = playerId, PlayerName = playerName, ServerName = serverName, Site = site, NotifyPlayer = notifyPlayer, Attempts = 0, State = ClaimStateCreated, HadAmbiguousFailure = false, TargetVoteCount = 0
}
;
pendingClaimTransactions[pendingClaimKey] = transaction;
}
else {
transaction.PlayerName = playerName;
transaction.NotifyPlayer |= notifyPlayer;
if (string.Equals(transaction.State, ClaimStateManualReview, StringComparison.Ordinal)) {
transaction.Attempts = 0;
transaction.State = ClaimStateCreated;
transaction.HadAmbiguousFailure = false;
}
}
SavePendingClaimsData();
QueuePendingClaimTransaction(pendingClaimKey);
}
private void QueuePendingClaimTransaction(string pendingClaimKey) {
PendingClaimTransaction transaction;
if (!pendingClaimTransactions.TryGetValue(pendingClaimKey, out transaction) || transaction == null) {
pendingClaimTransactions.Remove(pendingClaimKey);
SavePendingClaimsData();
return;
}
if (string.IsNullOrEmpty(transaction.TransactionId)) {
transaction.TransactionId = Guid.NewGuid().ToString("N");
SavePendingClaimsData();
}
if (string.Equals(transaction.State, ClaimStateConfirmed, StringComparison.Ordinal)) {
FinalizeConfirmedClaimTransaction(pendingClaimKey, transaction.TransactionId);
return;
}
if (string.Equals(transaction.State, ClaimStateManualReview, StringComparison.Ordinal)) {
return;
}
if (transaction.Attempts >= MaximumClaimAttempts) {
transaction.State = ClaimStateManualReview;
SavePendingClaimsData();
ConsoleError($"Claim transaction {transaction.TransactionId} for {transaction.PlayerName}/{transaction.PlayerId} on {transaction.Site} reached the retry limit and requires manual review.");
return;
}
string claimUrl;
if (!TryBuildClaimUrl(transaction, out claimUrl)) {
transaction.State = ClaimStateManualReview;
SavePendingClaimsData();
ConsoleError($"Unable to rebuild the claim URL for {transaction.PlayerName}/{transaction.PlayerId} on {transaction.Site} ({transaction.ServerName}). The transaction requires manual review.");
return;
}
string requestToken = $"{pendingClaimKey}:{transaction.TransactionId}";
if (!pendingClaims.Add(requestToken)) {
_Debug($"A claim request is already pending for {transaction.PlayerName}/{transaction.PlayerId} on {transaction.Site} ({transaction.ServerName}).");
return;
}
scheduledClaimRetries.Remove(pendingClaimKey);
_Debug($"Automatic claim URL: {MaskApiUrl(claimUrl)}");
QueueVoteRequest(() => {
PendingClaimTransaction currentTransaction;
if (!TryGetActiveClaimTransaction(pendingClaimKey, transaction.TransactionId, out currentTransaction)) {
pendingClaims.Remove(requestToken);
return;
}
currentTransaction.Attempts++;
currentTransaction.State = ClaimStateSent;
SavePendingClaimsData();
try {
webrequest.Enqueue(claimUrl, null, (code, response) => HandleClaimWebRequestCallback(code, response, pendingClaimKey, currentTransaction.TransactionId, requestToken), this, RequestMethod.GET, null, VoteApiRequestTimeout);
}
catch (Exception exception) {
pendingClaims.Remove(requestToken);
if (TryGetActiveClaimTransaction(pendingClaimKey, currentTransaction.TransactionId, out currentTransaction)) {
currentTransaction.State = ClaimStateUncertain;
currentTransaction.HadAmbiguousFailure = true;
SavePendingClaimsData();
}
ConsoleError($"Failed to enqueue the claim request for {transaction.PlayerName}/{transaction.PlayerId} on {transaction.Site}: {exception.Message}");
ScheduleClaimRetry(pendingClaimKey);
}
}
, true);
}
private bool TryBuildClaimUrl(PendingClaimTransaction transaction, out string claimUrl) {
claimUrl = null;
if (transaction == null || _config.Servers == null) {
return false;
}
KeyValuePair<string, Dictionary<string, string>> server = _config.Servers.FirstOrDefault(entry => entry.Key.Equals(transaction.ServerName, StringComparison.OrdinalIgnoreCase));
if (string.IsNullOrEmpty(server.Key) || server.Value == null) {
return false;
}
KeyValuePair<string, string> configuredVoteSite = server.Value.FirstOrDefault(entry => entry.Key.Equals(transaction.Site, StringComparison.OrdinalIgnoreCase));
if (string.IsNullOrEmpty(configuredVoteSite.Key)) {
return false;
}
string configuredSiteName;
Dictionary<string, string> apiConfiguration;
string serverId;
string serverKey;
if (!TryGetVoteSiteConfiguration(configuredVoteSite.Key, out configuredSiteName, out apiConfiguration) || !TryParseServerCredentials(configuredVoteSite.Value, out serverId, out serverKey)) {
return false;
}
try {
claimUrl = FormatApiUrl(apiConfiguration, ConfigDefaultKeys.apiClaim, transaction.PlayerId, transaction.PlayerName, serverId, serverKey);
return true;
}
catch (Exception exception) {
ConsoleError($"Failed to format the recovered claim URL for {configuredSiteName} on {server.Key}: {exception.Message}");
return false;
}
}
private void ScheduleClaimRetry(string pendingClaimKey) {
PendingClaimTransaction transaction;
if (!pendingClaimTransactions.TryGetValue(pendingClaimKey, out transaction) || transaction == null || string.Equals(transaction.State, ClaimStateManualReview, StringComparison.Ordinal) || !scheduledClaimRetries.Add(pendingClaimKey)) {
return;
}
if (!string.Equals(transaction.State, ClaimStateConfirmed, StringComparison.Ordinal) && transaction.Attempts >= MaximumClaimAttempts) {
scheduledClaimRetries.Remove(pendingClaimKey);
transaction.State = ClaimStateManualReview;
SavePendingClaimsData();
ConsoleError($"Claim transaction {transaction.TransactionId} for {transaction.PlayerName}/{transaction.PlayerId} on {transaction.Site} reached the retry limit and requires manual review.");
return;
}
float retryDelay = string.Equals(transaction.State, ClaimStateConfirmed, StringComparison.Ordinal) ? 10f : (transaction.Attempts <= 3 ? Math.Max(10f, transaction.Attempts * 10f) : 300f);
_Debug($"Claim retry for {transaction.PlayerName}/{transaction.PlayerId} on {transaction.Site} scheduled in {retryDelay:0} seconds.");
timer.Once(retryDelay, () => {
scheduledClaimRetries.Remove(pendingClaimKey);
if (pendingClaimTransactions.ContainsKey(pendingClaimKey)) {
QueuePendingClaimTransaction(pendingClaimKey);
}
}
);
}
private void RecoverPendingClaimTransactions() {
bool changed = false;
foreach (KeyValuePair<string, PendingClaimTransaction> entry in pendingClaimTransactions.ToList()) {
PendingClaimTransaction transaction = entry.Value;
if (transaction == null) {
pendingClaimTransactions.Remove(entry.Key);
changed = true;
continue;
}
if (string.IsNullOrEmpty(transaction.TransactionId)) {
transaction.TransactionId = Guid.NewGuid().ToString("N");
changed = true;
}
if (string.IsNullOrEmpty(transaction.State)) {
transaction.State = transaction.Attempts > 0 ? ClaimStateUncertain : ClaimStateCreated;
transaction.HadAmbiguousFailure = transaction.Attempts > 0;
changed = true;
}
else if (string.Equals(transaction.State, ClaimStateSent, StringComparison.Ordinal)) {
// The server stopped while a request was in flight, so the
// result is ambiguous and a later response 2 may be ours.
transaction.State = ClaimStateUncertain;
transaction.HadAmbiguousFailure = true;
changed = true;
}
}
if (changed) {
SavePendingClaimsData();
}
foreach (KeyValuePair<string, PendingClaimTransaction> entry in pendingClaimTransactions.ToList()) {
if (entry.Value == null || string.Equals(entry.Value.State, ClaimStateManualReview, StringComparison.Ordinal)) {
continue;
}
QueuePendingClaimTransaction(entry.Key);
}
}
private string GetPendingClaimKey(ulong playerId, string serverName, string site) {
return $"{playerId}:{serverName}:{site}";
}
private void RemovePendingClaimTransaction(string pendingClaimKey) {
scheduledClaimRetries.Remove(pendingClaimKey);
PendingClaimTransaction transaction;
if (pendingClaimTransactions.TryGetValue(pendingClaimKey, out transaction) && transaction != null) {
pendingClaims.Remove($"{pendingClaimKey}:{transaction.TransactionId}");
}
if (pendingClaimTransactions.Remove(pendingClaimKey)) {
SavePendingClaimsData();
}
}
private BasePlayer FindPlayer(ulong playerId) {
return BasePlayer.FindByID(playerId) ?? BasePlayer.FindSleeping(playerId);
}
private string GetResolvedPlayerName(BasePlayer player, string fallbackName, string steamId) {
if (player != null && !string.IsNullOrWhiteSpace(player.displayName)) {
return player.displayName;
}
return string.IsNullOrWhiteSpace(fallbackName) ? steamId : fallbackName;
}
private string FormatApiUrl(Dictionary<string, string> apiConfiguration, string apiKey, BasePlayer player, string serverId, string serverKey) {
if (player == null) {
throw new ArgumentNullException(nameof(player));
}
return FormatApiUrl(apiConfiguration, apiKey, player.userID, player.displayName, serverId, serverKey);
}
private string FormatApiUrl(Dictionary<string, string> apiConfiguration, string apiKey, ulong playerId, string playerName, string serverId, string serverKey) {
string apiLink = apiConfiguration[apiKey];
bool usernameApiEnabled = apiConfiguration[ConfigDefaultKeys.apiUsername].ToBool();
string encodedServerKey = Uri.EscapeDataString(serverKey);
string encodedServerId = Uri.EscapeDataString(serverId);
string encodedPlayerIdentifier = usernameApiEnabled ? Uri.EscapeDataString(playerName ?? string.Empty) : playerId.ToString();
// Extra format arguments are ignored when the URL does not use them.
// {0} = API key/token, {1} = Steam ID or username, {2} = server ID.
return string.Format(apiLink, encodedServerKey, encodedPlayerIdentifier, encodedServerId);
}
private bool TryGetVoteSiteConfiguration(string site, out string configuredSiteName, out Dictionary<string, string> apiConfiguration) {
configuredSiteName = null;
apiConfiguration = null;
if (_config.VoteSitesAPI == null) {
return false;
}
foreach (KeyValuePair<string, Dictionary<string, string>> voteSite in _config.VoteSitesAPI) {
if (!voteSite.Key.Equals(site, StringComparison.OrdinalIgnoreCase)) {
continue;
}
configuredSiteName = voteSite.Key;
apiConfiguration = voteSite.Value;
break;
}
if (apiConfiguration == null || !apiConfiguration.ContainsKey(ConfigDefaultKeys.apiClaim) || !apiConfiguration.ContainsKey(ConfigDefaultKeys.apiStatus) || !apiConfiguration.ContainsKey(ConfigDefaultKeys.apiLink) || !apiConfiguration.ContainsKey(ConfigDefaultKeys.apiUsername) || string.IsNullOrWhiteSpace(apiConfiguration[ConfigDefaultKeys.apiClaim]) || string.IsNullOrWhiteSpace(apiConfiguration[ConfigDefaultKeys.apiStatus]) || string.IsNullOrWhiteSpace(apiConfiguration[ConfigDefaultKeys.apiLink])) {
return false;
}
return true;
}
private bool TryParseServerCredentials(string configuredValue, out string serverId, out string serverKey) {
serverId = null;
serverKey = null;
if (string.IsNullOrWhiteSpace(configuredValue)) {
return false;
}
string[] idKey = configuredValue.Split(new[] {
':'
}
, 2);
if (idKey.Length != 2) {
return false;
}
serverId = idKey[0].Trim();
serverKey = idKey[1].Trim();
if (string.IsNullOrWhiteSpace(serverId) || string.IsNullOrWhiteSpace(serverKey)) {
return false;
}
if (serverId.Equals("ID", StringComparison.OrdinalIgnoreCase) || serverKey.Equals("KEY", StringComparison.OrdinalIgnoreCase)) {
return false;
}
return true;
}
private string MaskApiUrl(string url) {
if (string.IsNullOrEmpty(url)) {
return url;
}
return Regex.Replace(url, @"([?&][^=&]*(?:key|token|secret|auth|password)[^=&]*=)[^&]*", "$1********", RegexOptions.IgnoreCase);
}
private int GetStoredVoteCount(string steamId) {
if (string.IsNullOrEmpty(steamId) || DataFile[steamId] == null) {
return 0;
}
object storedValue = DataFile[steamId];
try {
return Convert.ToInt32(storedValue);
}
catch {
int parsedValue;
return int.TryParse(storedValue.ToString(), out parsedValue) ? parsedValue : 0;
}
}
private List<string> BuildRewardCommands(ulong playerId, string playerName, int playerVoteCount, bool cumulativeRewards) {
List<string> commands = new List<string>();
string steamId = playerId.ToString();
AddRewardCommands(commands, "@", steamId, playerName);
if (playerVoteCount == 1) {
AddRewardCommands(commands, "first", steamId, playerName);
}
foreach (KeyValuePair<string, List<string>> reward in _config.Rewards) {
int requiredVoteCount;
if (!TryGetNumericRewardVoteCount(reward.Key, out requiredVoteCount)) {
continue;
}
if (cumulativeRewards ? requiredVoteCount <= playerVoteCount : requiredVoteCount == playerVoteCount) {
AddRewardCommands(commands, reward.Key, steamId, playerName);
}
}
return commands;
}
private void AddRewardCommands(List<string> destination, string rewardKey, string steamId, string playerName) {
List<string> configuredCommands;
if (destination == null || !_config.Rewards.TryGetValue(rewardKey, out configuredCommands) || configuredCommands == null) {
return;
}
foreach (string configuredCommand in configuredCommands) {
if (string.IsNullOrWhiteSpace(configuredCommand)) {
continue;
}
string command = ParseRewardCommand(steamId, playerName, configuredCommand);
if (!string.IsNullOrWhiteSpace(command)) {
destination.Add(command);
}
}
}
private bool QueuePendingRewardTransaction(string steamId, string transactionId, List<string> commands) {
if (string.IsNullOrWhiteSpace(steamId) || string.IsNullOrWhiteSpace(transactionId) || commands == null || commands.Count == 0) {
return false;
}
List<string> pendingCommands;
if (!pendingRewardCommands.TryGetValue(steamId, out pendingCommands) || pendingCommands == null) {
pendingCommands = new List<string>();
pendingRewardCommands[steamId] = pendingCommands;
}
string marker = GetPendingRewardTransactionMarker(transactionId);
if (pendingCommands.Contains(marker)) {
return true;
}
int originalCount = pendingCommands.Count;
pendingCommands.Add(marker);
pendingCommands.AddRange(commands.Where(command => !string.IsNullOrWhiteSpace(command)));
if (!SavePendingRewardsData()) {
pendingCommands.RemoveRange(originalCount, pendingCommands.Count - originalCount);
if (pendingCommands.Count == 0) {
pendingRewardCommands.Remove(steamId);
}
return false;
}
_Debug($"Queued {commands.Count} pending reward command(s) for {steamId}, transaction {transactionId}.");
return true;
}
private string GetPendingRewardTransactionMarker(string transactionId) {
return PendingRewardTransactionPrefix + transactionId;
}
private bool IsPendingRewardTransactionMarker(string value) {
return !string.IsNullOrEmpty(value) && value.StartsWith(PendingRewardTransactionPrefix, StringComparison.Ordinal);
}
private bool TryGetNumericRewardVoteCount(string rewardKey, out int requiredVoteCount) {
return int.TryParse(rewardKey, out requiredVoteCount) && requiredVoteCount > 0;
}
private bool HasRewardCommands(List<string> rewardCommands) {
return rewardCommands != null && rewardCommands.Any(command => !string.IsNullOrWhiteSpace(command));
}
private string GetRewardDescription(string rewardKey, string playerId) {
string description;
if (_config.RewardDescriptions.TryGetValue(rewardKey, out description) && !string.IsNullOrWhiteSpace(description)) {
return description;
}
return _lang("RewardDescriptionMissing", playerId);
}
private int GetRewardDisplayOrder(string rewardKey) {
if (rewardKey == "@") {
return int.MinValue;
}
if (rewardKey == "first") {
return int.MinValue + 1;
}
int requiredVoteCount;
return TryGetNumericRewardVoteCount(rewardKey, out requiredVoteCount) ? requiredVoteCount : int.MaxValue;
}
private bool DeliverPendingRewards(BasePlayer player, bool notifyPlayer = true) {
if (player == null || !player.IsConnected || player.IsSleeping()) {
return false;
}
List<string> commands;
if (!pendingRewardCommands.TryGetValue(player.UserIDString, out commands) || commands == null || commands.Count == 0) {
return false;
}
bool deliveredCommand = false;
while (pendingRewardCommands.TryGetValue(player.UserIDString, out commands) && commands != null && commands.Count > 0) {
string command = commands[0];
commands.RemoveAt(0);
bool removedPlayerEntry = commands.Count == 0;
if (removedPlayerEntry) {
pendingRewardCommands.Remove(player.UserIDString);
}
// Persist removal before execution. This gives at-most-once delivery
// across crashes and prevents a command from being replayed after it
// has already been dispatched to the server console.
if (!SavePendingRewardsData()) {
if (removedPlayerEntry) {
pendingRewardCommands[player.UserIDString] = commands;
}
commands.Insert(0, command);
return deliveredCommand;
}
if (IsPendingRewardTransactionMarker(command)) {
continue;
}
if (string.IsNullOrWhiteSpace(command)) {
continue;
}
_Debug($"Delivering pending reward command to {player.displayName}/{player.UserIDString}: {command}");
rust.RunServerCommand(command);
deliveredCommand = true;
}
if (deliveredCommand && notifyPlayer) {
player.ChatMessage(_lang("PendingRewardsDelivered", player.UserIDString, _config.PluginSettings[ConfigDefaultKeys.Prefix]));
}
return deliveredCommand;
}
private string ParseRewardCommand(string steamId, string playerName, string command) {
string safePlayerName = string.IsNullOrWhiteSpace(playerName) ? steamId : playerName .Replace("\r", " ") .Replace("\n", " ") .Replace(";", string.Empty) .Replace("\"", "'");
return command .Replace("{playerid}", steamId) .Replace("{playername}", safePlayerName);
}
private void CheckIfPlayerDataExists(BasePlayer player) {
_Debug("------------------------------");
_Debug("Method: CheckIfPlayerDataExists");
_Debug($"Player: {player.displayName}/{player.UserIDString}");
if (DataFile[player.UserIDString] == null) {
_Debug($"{player.displayName} data does not exist. Creating new entry now.");
DataFile[player.UserIDString] = 0;
SaveDataFile(DataFile);
_Debug($"{player.displayName} Data has been created.");
}
}
private void ResetAllVoteData() {
_Debug("------------------------------");
_Debug("Method: ResetAllVoteData");
foreach (KeyValuePair<string, object> player in DataFile.ToList()) {
DataFile[player.Key] = 0;
_Debug($"Player {player.Key} vote count reset...");
}
SaveDataFile(DataFile);
pendingStatusChecks.Clear();
statusRequestTokens.Clear();
voteRequestQueue.Clear();
if (pendingRewardCommands.Count > 0) {
pendingRewardCommands.Clear();
SavePendingRewardsData();
_Debug("All pending vote rewards have been cleared together with the vote data.");
}
if (pendingClaimTransactions.Count > 0) {
pendingClaimTransactions.Clear();
pendingClaims.Clear();
scheduledClaimRetries.Clear();
SavePendingClaimsData();
_Debug("All pending vote claim transactions have been cleared together with the vote data.");
}
}
private void WarnConfigurationOnce(string warningKey, string message) {
if (reportedConfigurationWarnings.Add(warningKey)) {
ConsoleWarn(message);
}
}
private void CheckVotingStatus(BasePlayer player, bool notifyPlayer = true) {
if (player == null || !player.IsConnected) {
return;
}
_Debug("------------------------------");
_Debug("Method: CheckVotingStatus");
_Debug($"Player: {player.displayName}/{player.UserIDString}");
bool waitMessageSent = false;
foreach (KeyValuePair<string, Dictionary<string, string>> server in _config.Servers) {
if (server.Value == null) {
continue;
}
_Debug($"Server: {server.Key}");
foreach (KeyValuePair<string, string> configuredVoteSite in server.Value) {
string configuredSiteName;
Dictionary<string, string> apiConfiguration;
if (!TryGetVoteSiteConfiguration(configuredVoteSite.Key, out configuredSiteName, out apiConfiguration)) {
WarnConfigur
// ... [Code preview truncated due to length. Full source in attached file] ...