C#:
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using Facepunch.Extend;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Oxide.Core;
using Oxide.Core.Configuration;
using Oxide.Core.Libraries;
using Oxide.Core.Plugins;
using UnityEngine;
namespace Oxide.Plugins {
[Info("Easy Vote Extended", "dFxPhoeniX & TimRS", "3.3.2")]
[Description("The best Rust server voting system")]
public class EasyVoteExtended : RustPlugin {
////////////////////////////////////////////////////////////
// Runtime State
////////////////////////////////////////////////////////////
private readonly HashSet<string> pendingStatusChecks = new HashSet<string>();
private readonly Dictionary<string, string> statusRequestTokens = new Dictionary<string, string>();
private readonly Dictionary<ulong, string> scheduledVoteChecks = new Dictionary<ulong, string>();
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 Dictionary<string, string> scheduledClaimRetries = new Dictionary<string, string>();
private Dictionary<string, List<string>> pendingRewardCommands = new Dictionary<string, List<string>>();
private Dictionary<string, PendingClaimTransaction> pendingClaimTransactions = new Dictionary<string, PendingClaimTransaction>();
private readonly Dictionary<string, string> activeClaimRequests = new Dictionary<string, string>();
private readonly HashSet<ulong> finalizingPlayers = new HashSet<ulong>();
private readonly HashSet<ulong> deliveringPlayers = new HashSet<ulong>();
private readonly Dictionary<string, long> storageWarningTimes = new Dictionary<string, long>();
private readonly Queue<DiscordNotification> discordQueue = new Queue<DiscordNotification>();
private int runtimeGeneration;
private bool configurationLoaded;
private bool dataLoaded;
private bool unloading;
private bool resetInProgress;
private bool wipeResetRequested;
private bool debugEnabled;
private bool claimsDirty;
private VoteResetJournal pendingResetJournal;
private string discordRequestToken;
private long discordNextAttemptUtcTicks;
private int consecutiveClaimRequests;
////////////////////////////////////////////////////////////
// Constants
////////////////////////////////////////////////////////////
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 float VoteApiRequestTimeout = 15f;
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 const int MaximumCommandsPerDelivery = 32;
private const float LocalProcessingInterval = 5f;
private const string ClaimStateClosed = "Closed";
private const string ResetJournalDataFileName = "EasyVoteExtended_ResetJournal";
////////////////////////////////////////////////////////////
// Models
////////////////////////////////////////////////////////////
private enum RewardDeliveryResult {
None, Pending, Partial, Dispatched, Failed
}
private class VoteResetJournal {
public string OperationId;
public string SteamId;
public bool AllPlayers;
}
private class DiscordNotification {
public string Url;
public string Body;
public int Attempts;
}
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;
public int RecoveryAttempts;
public long NextAttemptUtcTicks;
public long CreatedUtcTicks;
public bool NeedsStatusCheck;
public List<string> RewardCommands;
public string ReviewReason;
}
////////////////////////////////////////////////////////////
// Oxide Hooks
////////////////////////////////////////////////////////////
private void Init() {
if (!configurationLoaded || _config == null) {
ConsoleError("The plugin is paused because its configuration could not be loaded. Repair the original file and reload the plugin.");
return;
}
LoadMessages();
debugEnabled = _config.DebugSettings[ConfigDefaultKeys.DebugEnabled].ToBool();
if (_config.DebugSettings[ConfigDefaultKeys.VerboseDebugEnabled].ToBool()) {
ConsoleWarn("Verbose debugging overrides voting API responses. Do not enable it on a production server.");
}
dataLoaded = LoadVoteData();
dataLoaded &= LoadPendingRewardsData();
dataLoaded &= LoadPendingClaimsData();
if (!dataLoaded) {
ConsoleError("Voting is paused to protect unreadable data. Original files were not overwritten. Repair the files and reload the plugin.");
return;
}
if (!RecoverResetJournal()) {
return;
}
if (wipeResetRequested) {
wipeResetRequested = false;
ResetAllVoteData();
}
}
private void OnServerInitialized() {
if (!configurationLoaded || !dataLoaded) {
return;
}
ConsoleLog(CanProcessVotes() ? "Easy Vote Extended has been initialized..." : "Easy Vote Extended is waiting for its pending reset to finish safely.");
StartRequestQueueProcessor();
StartAutomaticVoteChecks();
timer.Every(LocalProcessingInterval, ProcessLocalPendingWork);
timer.Every(1f, ProcessDiscordQueue);
RecoverPendingClaimTransactions();
timer.Once(2f, () => {
if (!CanProcessVotes()) {
return;
}
foreach (BasePlayer player in BasePlayer.activePlayerList.ToArray()) {
if (player == null || !player.IsConnected) {
continue;
}
CheckIfPlayerDataExists(player);
DeliverPendingRewards(player);
CheckVotingStatus(player, false);
}
}
);
}
private void Unload() {
unloading = true;
activeClaimRequests.Clear();
pendingStatusChecks.Clear();
statusRequestTokens.Clear();
scheduledVoteChecks.Clear();
reportedConfigurationWarnings.Clear();
claimRequestQueue.Clear();
voteRequestQueue.Clear();
commandCooldowns.Clear();
scheduledClaimRetries.Clear();
discordQueue.Clear();
discordRequestToken = null;
// Preserve a reset intent if storage recovered just before unloading.
if (dataLoaded && pendingResetJournal != null) {
TryWriteJson(GetDataPath(ResetJournalDataFileName), pendingResetJournal);
}
// Do not overwrite unreadable files or a reset that still needs recovery.
if (dataLoaded && !resetInProgress && claimsDirty) {
SavePendingClaimsData();
}
}
private void OnNewSave(string filename) {
if (!configurationLoaded || !_config.PluginSettings[ConfigDefaultKeys.ClearRewardsOnWipe].ToBool()) {
return;
}
if (!dataLoaded || resetInProgress) {
wipeResetRequested = true;
return;
}
ConsoleLog("New map data detected. Resetting stored votes and pending rewards.");
ResetAllVoteData();
}
private void OnPlayerConnected(BasePlayer player) {
if (!CanProcessVotes() || player == null) {
return;
}
CheckIfPlayerDataExists(player);
ulong connectedPlayerId = player.userID;
timer.Once(2f, () => {
if (!CanProcessVotes()) {
return;
}
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) {
if (!CanProcessVotes() || player == null) {
return;
}
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) {
PendingClaimTransaction transaction;
if (!ConsumeClaimRequest(pendingClaimKey, transactionId, requestToken, out transaction)) {
return;
}
if (debugEnabled) {
_Debug($"Claim response: transaction={transaction.TransactionId}, player={transaction.PlayerId}, site={transaction.Site}, HTTP={code}.");
}
if (code != 200) {
FailClaimRequest(pendingClaimKey, transaction, $"Claim HTTP status {code}.");
return;
}
response = response?.Trim();
if (_config.DebugSettings[ConfigDefaultKeys.VerboseDebugEnabled].ToBool()) {
response = _config.DebugSettings[ConfigDefaultKeys.ClaimAPIRepsonseCode];
}
if (response == "1") {
ConfirmClaimTransaction(pendingClaimKey, transaction);
return;
}
if (response == "2" && transaction.HadAmbiguousFailure) {
// This recovery assumes only one consumer claims this external vote.
ConfirmClaimTransaction(pendingClaimKey, transaction);
return;
}
if (response == "0" && transaction.HadAmbiguousFailure) {
MarkClaimForReview(pendingClaimKey, transaction, "The claim returned 0 after an ambiguous request. Verify the provider before granting or discarding it.");
return;
}
if (response == "0" || response == "2") {
if (!CloseClaimTransaction(pendingClaimKey, transaction)) {
return;
}
BasePlayer player = FindPlayer(transaction.PlayerId);
if (transaction.NotifyPlayer && player != null && player.IsConnected) {
string messageKey = response == "2" ? "AlreadyVoted" : "ClaimStatus";
player.ChatMessage(_lang(messageKey, player.UserIDString, _config.PluginSettings[ConfigDefaultKeys.Prefix], transaction.Site, transaction.ServerName));
}
return;
}
FailClaimRequest(pendingClaimKey, transaction, "Unexpected claim response; expected plain 0, 1 or 2.");
}
private bool TryGetActiveClaimTransaction(string pendingClaimKey, string transactionId, out PendingClaimTransaction transaction) {
transaction = null;
return CanProcessVotes() && !string.IsNullOrEmpty(transactionId) && pendingClaimTransactions.TryGetValue(pendingClaimKey, out transaction) && transaction != null && string.Equals(transaction.TransactionId, transactionId, StringComparison.Ordinal);
}
private void ConfirmClaimTransaction(string pendingClaimKey, PendingClaimTransaction transaction) {
PendingClaimTransaction current;
if (transaction == null || !TryGetActiveClaimTransaction(pendingClaimKey, transaction.TransactionId, out current) || !ReferenceEquals(current, transaction)) {
return;
}
if (transaction.TargetVoteCount <= 0) {
int highestVoteCount = GetStoredVoteCount(transaction.PlayerId.ToString());
foreach (PendingClaimTransaction pending in pendingClaimTransactions.Values) {
if (pending != null && pending.PlayerId == transaction.PlayerId && pending.State == ClaimStateConfirmed) {
highestVoteCount = Math.Max(highestVoteCount, pending.TargetVoteCount);
}
}
if (highestVoteCount == int.MaxValue) {
MarkClaimForReview(pendingClaimKey, transaction, "The vote counter has reached its maximum value. Correct the counter before resolving this claim.");
return;
}
transaction.TargetVoteCount = highestVoteCount + 1;
}
if (transaction.RewardCommands == null) {
string playerName = GetResolvedPlayerName(FindPlayer(transaction.PlayerId), transaction.PlayerName, transaction.PlayerId.ToString());
transaction.RewardCommands = BuildRewardCommands(transaction.PlayerId, playerName, transaction.TargetVoteCount, _config.PluginSettings[ConfigDefaultKeys.RewardIsCumulative].ToBool());
}
transaction.State = ClaimStateConfirmed;
transaction.NeedsStatusCheck = false;
transaction.NextAttemptUtcTicks = 0;
if (!SavePendingClaimsData()) {
ScheduleClaimRetry(pendingClaimKey);
return;
}
if (debugEnabled) {
_Debug($"Claim confirmed: transaction={transaction.TransactionId}, player={transaction.PlayerId}, target votes={transaction.TargetVoteCount}.");
}
FinalizeConfirmedClaimTransaction(pendingClaimKey, transaction.TransactionId);
}
private void FinalizeConfirmedClaimTransaction(string pendingClaimKey, string transactionId) {
PendingClaimTransaction transaction;
if (!TryGetActiveClaimTransaction(pendingClaimKey, transactionId, out transaction) || transaction.State != ClaimStateConfirmed) {
return;
}
if (transaction.NextAttemptUtcTicks > DateTime.UtcNow.Ticks || !finalizingPlayers.Add(transaction.PlayerId)) {
return;
}
try {
if (pendingClaimTransactions.Values.Any(other => other != null && !ReferenceEquals(other, transaction) && other.PlayerId == transaction.PlayerId && other.State == ClaimStateConfirmed && other.TargetVoteCount > 0 && other.TargetVoteCount < transaction.TargetVoteCount)) {
ScheduleClaimRetry(pendingClaimKey);
return;
}
if (transaction.TargetVoteCount <= 0 || transaction.RewardCommands == null) {
finalizingPlayers.Remove(transaction.PlayerId);
ConfirmClaimTransaction(pendingClaimKey, transaction);
return;
}
// Persist confirmation before changing the counter or preparing delivery.
if (claimsDirty && !SavePendingClaimsData()) {
ScheduleClaimRetry(pendingClaimKey);
return;
}
string steamId = transaction.PlayerId.ToString();
int currentVoteCount = GetStoredVoteCount(steamId);
if (currentVoteCount < transaction.TargetVoteCount && !TrySetStoredVoteCount(steamId, transaction.TargetVoteCount)) {
ScheduleClaimRetry(pendingClaimKey);
return;
}
bool rewardConfigured = transaction.RewardCommands.Any(command => !string.IsNullOrWhiteSpace(command));
if (rewardConfigured && !QueuePendingRewardTransaction(steamId, transaction.TransactionId, transaction.RewardCommands)) {
ScheduleClaimRetry(pendingClaimKey);
return;
}
// Delivery remains locked while this confirmed transaction exists.
if (!RemovePendingClaimTransaction(pendingClaimKey)) {
ScheduleClaimRetry(pendingClaimKey);
return;
}
BasePlayer player = FindPlayer(transaction.PlayerId);
string playerName = GetResolvedPlayerName(player, transaction.PlayerName, steamId);
int generation = runtimeGeneration;
RewardDeliveryResult deliveryResult = rewardConfigured ? DeliverPendingRewards(player, false) : RewardDeliveryResult.None;
if (!CanProcessVotes() || generation != runtimeGeneration) {
return;
}
bool dispatched = deliveryResult == RewardDeliveryResult.Dispatched;
bool dispatchFailed = deliveryResult == RewardDeliveryResult.Failed;
string displayedVoteCount = GetStoredVoteCount(steamId).ToString();
if (player != null && player.IsConnected) {
string messageKey = !rewardConfigured ? "ThankYouNoReward" : (dispatchFailed ? "ThankYouDispatchFailed" : (dispatched ? "ThankYou" : "ThankYouPending"));
player.ChatMessage(_lang(messageKey, player.UserIDString, _config.PluginSettings[ConfigDefaultKeys.Prefix], displayedVoteCount, transaction.Site));
}
if (_config.Discord[ConfigDefaultKeys.DiscordEnabled].ToBool()) {
string messageKey = !rewardConfigured ? "DiscordWebhookMessageNoReward" : (dispatchFailed ? "DiscordWebhookMessageFailed" : (dispatched ? "DiscordWebhookMessage" : "DiscordWebhookMessagePending"));
DiscordSendMessage(_lang(messageKey, null, playerName, transaction.ServerName, transaction.Site));
}
if (_config.NotificationSettings[ConfigDefaultKeys.GlobalChatAnnouncements].ToBool()) {
string messageKey = !rewardConfigured ? "GlobalChatAnnouncementsNoReward" : (dispatchFailed ? "GlobalChatAnnouncementsFailed" : (dispatched ? "GlobalChatAnnouncements" : "GlobalChatAnnouncementsPending"));
foreach (BasePlayer recipient in BasePlayer.activePlayerList.ToArray()) {
if (recipient != null && recipient.IsConnected) {
recipient.ChatMessage(_lang(messageKey, recipient.UserIDString, _config.PluginSettings[ConfigDefaultKeys.Prefix], playerName, displayedVoteCount));
}
}
}
}
finally {
finalizingPlayers.Remove(transaction.PlayerId);
}
}
private void HandleStatusWebRequestCallback(int code, string response, ulong playerId, string playerName, string serverName, string site, bool notifyPlayer, string pendingStatusKey, string statusRequestToken) {
if (!TryConsumeStatusRequest(pendingStatusKey, statusRequestToken)) {
return;
}
if (debugEnabled) {
_Debug($"Vote status response: player={playerId}, server={serverName}, site={site}, HTTP={code}.");
}
if (code != 200) {
ConsoleWarn($"Vote status request failed for {playerId} on {site} ({serverName}): HTTP {code}. Check the provider, connectivity and configured credentials.");
return;
}
response = response?.Trim();
if (_config.DebugSettings[ConfigDefaultKeys.VerboseDebugEnabled].ToBool()) {
response = _config.DebugSettings[ConfigDefaultKeys.CheckAPIResponseCode];
}
string pendingClaimKey = GetPendingClaimKey(playerId, serverName, site);
if (pendingClaimTransactions.ContainsKey(pendingClaimKey)) {
return;
}
if (response == "1") {
// Keep the exact username used by this status request for its claim.
EnqueueClaimRequest(playerId, playerName, serverName, site, notifyPlayer);
return;
}
BasePlayer player = FindPlayer(playerId);
if (response == "0" || response == "2") {
if (notifyPlayer && player != null && player.IsConnected) {
if (response == "0") {
player.ChatMessage(_lang("NoRewards", player.UserIDString, _config.PluginSettings[ConfigDefaultKeys.Prefix], serverName, site));
}
else {
player.ChatMessage(_lang("AlreadyVoted", player.UserIDString, _config.PluginSettings[ConfigDefaultKeys.Prefix], site));
}
}
return;
}
ConsoleWarn($"Unexpected status response from {site} for {playerId} on {serverName}. Expected plain 0, 1 or 2.");
}
private bool IsActiveStatusRequest(string pendingStatusKey, string statusRequestToken) {
string activeToken;
return CanProcessVotes() && !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) {
if (!CanProcessVotes()) {
return;
}
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, State = ClaimStateCreated, CreatedUtcTicks = DateTime.UtcNow.Ticks
}
;
pendingClaimTransactions[pendingClaimKey] = transaction;
}
else {
transaction.NotifyPlayer |= notifyPlayer;
}
if (!SavePendingClaimsData()) {
ScheduleClaimRetry(pendingClaimKey);
return;
}
QueuePendingClaimTransaction(pendingClaimKey);
}
private void QueuePendingClaimTransaction(string pendingClaimKey) {
PendingClaimTransaction transaction;
if (!CanProcessVotes() || !pendingClaimTransactions.TryGetValue(pendingClaimKey, out transaction) || transaction == null) {
return;
}
if (activeClaimRequests.ContainsKey(pendingClaimKey)) {
return;
}
if (transaction.NextAttemptUtcTicks > DateTime.UtcNow.Ticks) {
ScheduleClaimRetry(pendingClaimKey);
return;
}
if (transaction.State == ClaimStateConfirmed) {
FinalizeConfirmedClaimTransaction(pendingClaimKey, transaction.TransactionId);
return;
}
if (transaction.State == ClaimStateClosed) {
if (!SavePendingClaimsData() || !RemovePendingClaimTransaction(pendingClaimKey)) {
ScheduleClaimRetry(pendingClaimKey);
}
return;
}
if (transaction.State == ClaimStateManualReview) {
return;
}
bool reconcile = transaction.NeedsStatusCheck || transaction.State == ClaimStateUncertain;
int attempts = reconcile ? transaction.RecoveryAttempts : transaction.Attempts;
if (attempts >= MaximumClaimAttempts) {
MarkClaimForReview(pendingClaimKey, transaction, "The retry limit was reached. The transaction has been preserved for manual review.");
return;
}
string requestUrl;
if (!TryBuildTransactionUrl(transaction, reconcile ? ConfigDefaultKeys.apiStatus : ConfigDefaultKeys.apiClaim, out requestUrl)) {
MarkClaimForReview(pendingClaimKey, transaction, "The configured provider or credentials could not be used to build the request URL.");
return;
}
string transactionId = transaction.TransactionId;
string requestToken = Guid.NewGuid().ToString("N");
activeClaimRequests[pendingClaimKey] = requestToken;
scheduledClaimRetries.Remove(pendingClaimKey);
QueueVoteRequest(() => {
PendingClaimTransaction current;
string activeToken;
if (!TryGetActiveClaimTransaction(pendingClaimKey, transactionId, out current) || !activeClaimRequests.TryGetValue(pendingClaimKey, out activeToken) || activeToken != requestToken) {
ReleaseClaimRequest(pendingClaimKey, requestToken);
return;
}
string previousState = current.State;
if (reconcile) {
current.RecoveryAttempts++;
}
else {
current.Attempts++;
}
current.State = ClaimStateSent;
current.NextAttemptUtcTicks = 0;
if (!SavePendingClaimsData()) {
if (reconcile) {
current.RecoveryAttempts--;
}
else {
current.Attempts--;
}
current.State = previousState;
ReleaseClaimRequest(pendingClaimKey, requestToken);
ScheduleClaimRetry(pendingClaimKey);
return;
}
if (debugEnabled) {
_Debug($"Sending {(reconcile ? "recovery status" : "claim")} request: transaction={transactionId}, player={current.PlayerId}, site={current.Site}, attempt={(reconcile ? current.RecoveryAttempts : current.Attempts)}.");
}
Action<int, string> callback = (code, response) => {
if (reconcile) {
HandleClaimReconciliation(code, response, pendingClaimKey, transactionId, requestToken);
}
else {
HandleClaimWebRequestCallback(code, response, pendingClaimKey, transactionId, requestToken);
}
}
;
try {
webrequest.Enqueue(requestUrl, null, (code, response) => NextTick(() => callback(code, response)), this, RequestMethod.GET, null, VoteApiRequestTimeout);
timer.Once(VoteApiRequestTimeout + 5f, () => callback(-1, null));
}
catch (Exception) {
callback(-1, null);
}
}
, true);
}
private void ScheduleClaimRetry(string pendingClaimKey) {
PendingClaimTransaction transaction;
if (!CanProcessVotes() || !pendingClaimTransactions.TryGetValue(pendingClaimKey, out transaction) || transaction == null || transaction.State == ClaimStateManualReview) {
return;
}
string token;
if (scheduledClaimRetries.TryGetValue(pendingClaimKey, out token)) {
return;
}
bool localCompletion = transaction.State == ClaimStateConfirmed || transaction.State == ClaimStateClosed;
int attempts = transaction.NeedsStatusCheck ? transaction.RecoveryAttempts : transaction.Attempts;
if (!localCompletion && attempts >= MaximumClaimAttempts && !activeClaimRequests.ContainsKey(pendingClaimKey)) {
MarkClaimForReview(pendingClaimKey, transaction, "The retry limit was reached. Inspect the preserved transaction with eve.pending.");
return;
}
long now = DateTime.UtcNow.Ticks;
if (transaction.NextAttemptUtcTicks <= now) {
int backoffAttempts = Math.Max(transaction.Attempts, transaction.RecoveryAttempts);
double delay = localCompletion ? 10d : (backoffAttempts <= 3 ? Math.Max(10d, backoffAttempts * 10d) : 300d);
transaction.NextAttemptUtcTicks = now + (long)(delay * TimeSpan.TicksPerSecond);
SavePendingClaimsData();
}
float remaining = (float)Math.Max(0.1d, Math.Min(300d, (transaction.NextAttemptUtcTicks - now) / (double)TimeSpan.TicksPerSecond));
string transactionId = transaction.TransactionId;
token = Guid.NewGuid().ToString("N");
string retryToken = token;
scheduledClaimRetries[pendingClaimKey] = retryToken;
if (debugEnabled) {
_Debug($"Retry scheduled: transaction={transactionId}, state={transaction.State}, delay={remaining:0.0}s.");
}
timer.Once(remaining, () => {
string currentToken;
PendingClaimTransaction current;
if (!scheduledClaimRetries.TryGetValue(pendingClaimKey, out currentToken) || currentToken != retryToken || !TryGetActiveClaimTransaction(pendingClaimKey, transactionId, out current)) {
return;
}
scheduledClaimRetries.Remove(pendingClaimKey);
QueuePendingClaimTransaction(pendingClaimKey);
}
);
}
private void RecoverPendingClaimTransactions() {
if (!CanProcessVotes()) {
return;
}
bool changed = false;
foreach (PendingClaimTransaction transaction in pendingClaimTransactions.Values) {
if (string.IsNullOrEmpty(transaction.TransactionId)) {
transaction.TransactionId = Guid.NewGuid().ToString("N");
changed = true;
}
if (transaction.CreatedUtcTicks == 0) {
transaction.CreatedUtcTicks = DateTime.UtcNow.Ticks;
changed = true;
}
if (string.IsNullOrEmpty(transaction.State) || transaction.State == ClaimStateSent) {
transaction.State = transaction.Attempts > 0 ? ClaimStateUncertain : ClaimStateCreated;
transaction.HadAmbiguousFailure |= transaction.Attempts > 0;
changed = true;
}
if (transaction.State == ClaimStateUncertain) {
transaction.NeedsStatusCheck = true;
transaction.HadAmbiguousFailure = true;
changed = true;
}
}
if (changed && !SavePendingClaimsData()) {
foreach (string key in pendingClaimTransactions.Keys.ToArray()) {
ScheduleClaimRetry(key);
}
return;
}
foreach (KeyValuePair<string, PendingClaimTransaction> entry in pendingClaimTransactions.OrderBy(entry => entry.Value.TargetVoteCount).ToArray()) {
QueuePendingClaimTransaction(entry.Key);
}
}
private string GetPendingClaimKey(ulong playerId, string serverName, string site) {
return $"{playerId}:{serverName}:{site}";
}
private bool RemovePendingClaimTransaction(string pendingClaimKey) {
PendingClaimTransaction transaction;
if (!pendingClaimTransactions.TryGetValue(pendingClaimKey, out transaction)) {
return true;
}
pendingClaimTransactions.Remove(pendingClaimKey);
if (!SavePendingClaimsData()) {
// Keep delivery locked until the removal is durable.
pendingClaimTransactions[pendingClaimKey] = transaction;
return false;
}
scheduledClaimRetries.Remove(pendingClaimKey);
reportedConfigurationWarnings.Remove($"review:{transaction.TransactionId}");
string requestToken;
if (activeClaimRequests.TryGetValue(pendingClaimKey, out requestToken)) {
ReleaseClaimRequest(pendingClaimKey, requestToken);
}
return true;
}
private bool ConsumeClaimRequest(string pendingClaimKey, string transactionId, string requestToken, out PendingClaimTransaction transaction) {
transaction = null;
string activeToken;
if (!TryGetActiveClaimTransaction(pendingClaimKey, transactionId, out transaction) || !activeClaimRequests.TryGetValue(pendingClaimKey, out activeToken) || activeToken != requestToken) {
return false;
}
ReleaseClaimRequest(pendingClaimKey, requestToken);
return true;
}
private void ReleaseClaimRequest(string pendingClaimKey, string requestToken) {
string activeToken;
if (activeClaimRequests.TryGetValue(pendingClaimKey, out activeToken) && activeToken == requestToken) {
activeClaimRequests.Remove(pendingClaimKey);
}
}
private void FailClaimRequest(string key, PendingClaimTransaction transaction, string reason) {
transaction.State = ClaimStateUncertain;
transaction.HadAmbiguousFailure = true;
transaction.NeedsStatusCheck = true;
transaction.ReviewReason = reason;
SavePendingClaimsData();
ConsoleWarn($"Vote transaction {transaction.TransactionId} for {transaction.PlayerId} on {transaction.Site}: {reason}");
ScheduleClaimRetry(key);
}
private void MarkClaimForReview(string key, PendingClaimTransaction transaction, string reason) {
transaction.State = ClaimStateManualReview;
transaction.ReviewReason = reason;
transaction.NextAttemptUtcTicks = 0;
scheduledClaimRetries.Remove(key);
SavePendingClaimsData();
ConsoleWarn($"Vote transaction {transaction.TransactionId} for {transaction.PlayerId} requires manual review: {reason} Use eve.pending and eve.resolve.");
}
private bool CloseClaimTransaction(string key, PendingClaimTransaction transaction) {
transaction.State = ClaimStateClosed;
transaction.NextAttemptUtcTicks = 0;
if (!SavePendingClaimsData() || !RemovePendingClaimTransaction(key)) {
ScheduleClaimRetry(key);
return false;
}
return true;
}
private void HandleClaimReconciliation(int code, string response, string key, string transactionId, string requestToken) {
PendingClaimTransaction transaction;
if (!ConsumeClaimRequest(key, transactionId, requestToken, out transaction)) {
return;
}
if (debugEnabled) {
_Debug($"Recovery response: transaction={transaction.TransactionId}, player={transaction.PlayerId}, site={transaction.Site}, HTTP={code}.");
}
if (code != 200) {
FailClaimRequest(key, transaction, $"Recovery status HTTP {code}.");
return;
}
response = response?.Trim();
if (_config.DebugSettings[ConfigDefaultKeys.VerboseDebugEnabled].ToBool()) {
response = _config.DebugSettings[ConfigDefaultKeys.CheckAPIResponseCode];
}
if (response == "1") {
transaction.State = ClaimStateCreated;
transaction.RecoveryAttempts = 0;
transaction.NeedsStatusCheck = false;
transaction.NextAttemptUtcTicks = 0;
if (!SavePendingClaimsData()) {
ScheduleClaimRetry(key);
return;
}
QueuePendingClaimTransaction(key);
}
else if (response == "2" && transaction.HadAmbiguousFailure) {
ConfirmClaimTransaction(key, transaction);
}
else if (response == "0" || response == "2") {
MarkClaimForReview(key, transaction, "The provider no longer reports an unclaimed vote after an ambiguous request. Verify the outcome before resolving it.");
}
else {
FailClaimRequest(key, transaction, "Unexpected recovery status response; expected plain 0, 1 or 2.");
}
}
private bool TryBuildTransactionUrl(PendingClaimTransaction transaction, string apiKey, out string url) {
url = 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> site = server.Value.FirstOrDefault(entry => entry.Key.Equals(transaction.Site, StringComparison.OrdinalIgnoreCase));
string configuredSiteName;
Dictionary<string, string> apiConfiguration;
string serverId;
string serverKey;
if (string.IsNullOrEmpty(site.Key) || !TryGetVoteSiteConfiguration(site.Key, out configuredSiteName, out apiConfiguration) || !TryParseServerCredentials(site.Value, out serverId, out serverKey)) {
return false;
}
try {
url = FormatApiUrl(apiConfiguration, apiKey, transaction.PlayerId, transaction.PlayerName, serverId, serverKey);
Uri parsed;
return Uri.TryCreate(url, UriKind.Absolute, out parsed) && (parsed.Scheme == Uri.UriSchemeHttps || parsed.Scheme == Uri.UriSchemeHttp);
}
catch (Exception) {
return false;
}
}
private bool HasUncommittedRewards(ulong playerId) {
return pendingClaimTransactions.Values.Any(transaction => transaction != null && transaction.PlayerId == playerId && transaction.State == ClaimStateConfirmed);
}
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 int GetStoredVoteCount(string steamId) {
if (!dataLoaded || DataFile == null || string.IsNullOrWhiteSpace(steamId)) {
return 0;
}
object value = DataFile[steamId];
int count;
return value != null && int.TryParse(value.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out count) ? Math.Max(0, count) : 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;
}
// ... [Code preview truncated due to length. Full source in attached file] ...