C#:
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
using UnityEngine;
namespace Oxide.Plugins
{
[Info("RustServer.gg Vote", "TimRS", "1.3.1")]
[Description("Voting reward plugin for RustServers.gg")]
public class RSVote : RustPlugin
{
#region Vars
private const string ApiUrl = "https://rustservers.gg/vote-api.php";
private const string ServerPageUrl = "https://rustservers.gg/server/";
private const string NoteShortname = "note";
private const float RequestTimeout = 10f;
private const string ActionStatus = "status";
private const string ActionClaim = "claim";
// Response codes returned by the vote api.
private const int CodeNoVote = 0;
private const int CodeRewardPending = 1;
private const int CodeAlreadyClaimed = 2;
// Cooldown expiry per player, pruned periodically.
private readonly Dictionary<ulong, DateTime> cooldown = new Dictionary<ulong, DateTime>();
// Players with a request in flight, so a double command cannot claim twice.
private readonly HashSet<ulong> busy = new HashSet<ulong>();
#endregion
#region Oxide Hooks
private void Init()
{
foreach (var command in config.commands)
{
if (string.IsNullOrEmpty(command) == true)
{
continue;
}
cmd.AddChatCommand(command, this, cmdChat);
}
// Drop expired cooldowns only, so nobody gets an early reset.
timer.Every(300f, PruneCooldowns);
ValidateConfig();
Puts($"Loaded server with id '{config.serverId}'");
}
private void Unload()
{
cooldown.Clear();
busy.Clear();
}
protected override void LoadDefaultMessages()
{
lang.RegisterMessages(new Dictionary<string, string>
{
["Cooldown"] = "Please wait {0} more second(s) before trying again.",
["Checking"] = "Trying to claim reward...",
["NoVote"] = "Vote at the servers page to claim reward!\nCheck the note in your inventory for the link.",
["NoVoteNoNote"] = "Vote at the servers page to claim reward!\n{0}",
["NoteAlert"] = "A note with the vote link was placed in your inventory!",
["NoteName"] = "Vote Link",
["NoteText"] = "Vote here to claim your reward:\n\n{0}\n\nSelect the link and copy it with CTRL + C.",
["AlreadyClaimed"] = "You already claimed your reward(s)",
["RewardGiven"] = "You received {0} for voting!",
["RewardFailed"] = "Failed to claim reward, please contact the server owner!",
["RequestFailed"] = "The vote server could not be reached, please try again later.",
["NotConfigured"] = "Voting is not configured on this server, please contact the server owner!"
}, this);
}
#endregion
#region Commands
private void cmdChat(BasePlayer player, string command, string[] args)
{
var userId = player.userID;
if (IsConfigured() == false)
{
Message(player, "NotConfigured");
return;
}
DateTime expiry;
if (cooldown.TryGetValue(userId, out expiry) == true && expiry > DateTime.UtcNow)
{
var remaining = Math.Max(1, (int)Math.Ceiling((expiry - DateTime.UtcNow).TotalSeconds));
Message(player, "Cooldown", remaining);
return;
}
// A request is already running for this player, ignore the spam.
if (busy.Add(userId) == false)
{
return;
}
cooldown[userId] = DateTime.UtcNow.AddSeconds(config.cooldownSeconds);
Message(player, "Checking");
RequestStatus(player.UserIDString, userId);
}
#endregion
#region Core
private void RequestStatus(string steamId, ulong userId)
{
Execute(BuildUrl(ActionStatus, steamId), ActionStatus, userId, (code, response) =>
{
switch (code)
{
case CodeNoVote:
{
var player = BasePlayer.FindByID(userId);
if (GiveVoteNote(player) == true)
{
// The url is passed even though the default text does not use it,
// older lang files still have a {0} placeholder in this message.
Message(player, "NoVote", VotePageUrl());
ShowAlert(player, "NoteAlert");
}
else
{
Message(player, "NoVoteNoNote", VotePageUrl());
}
break;
}
case CodeRewardPending:
ClaimReward(steamId, userId);
return;
case CodeAlreadyClaimed:
MessageById(userId, "AlreadyClaimed");
break;
default:
PrintWarning($"Unexpected status code '{code}' for '{steamId}'");
MessageById(userId, "RequestFailed");
break;
}
busy.Remove(userId);
});
}
private void ClaimReward(string steamId, ulong userId)
{
// Pick and validate the reward up front: consuming the vote server side
// only to then fail handing something out would cost the player their vote.
var reward = SelectReward();
if (reward == null)
{
PrintWarning("No usable rewards are configured, check 'Reward items' and 'Reward Commands'");
MessageById(userId, "RewardFailed");
busy.Remove(userId);
return;
}
Execute(BuildUrl(ActionClaim, steamId), ActionClaim, userId, (code, response) =>
{
busy.Remove(userId);
// Only hand out the reward once the vote is actually consumed,
// otherwise the player could keep re-running the command.
if (code != CodeRewardPending)
{
PrintWarning($"Claim for '{steamId}' returned '{code}', no reward was given");
MessageById(userId, "RequestFailed");
return;
}
GiveReward(userId, reward);
});
}
private void GiveReward(ulong userId, IReward reward)
{
var player = BasePlayer.FindByID(userId);
if (player == null)
{
PrintWarning($"Player '{userId}' left before receiving {reward.Info()}, the vote was already claimed");
return;
}
if (reward.GiveTo(player) == false)
{
PrintWarning($"Failed to give reward to '{userId}' ({reward.Info()})");
Message(player, "RewardFailed");
return;
}
Puts($"Player {userId} received reward for voting ({reward.Info()})");
Message(player, "RewardGiven", reward.Info());
}
private IReward SelectReward()
{
var items = UsableRewards(config.rewardItems);
var commands = UsableRewards(config.rewardCommands);
if (items.Count == 0 && commands.Count == 0)
{
return null;
}
if (items.Count == 0)
{
return Pick(commands);
}
if (commands.Count == 0)
{
return Pick(items);
}
return Core.Random.Range(0, 2) == 0
? Pick(items)
: Pick(commands);
}
private static IReward Pick(List<IReward> rewards)
{
return rewards[Core.Random.Range(0, rewards.Count)];
}
private static List<IReward> UsableRewards<T>(T[] rewards) where T : IReward
{
var usable = new List<IReward>();
if (rewards == null)
{
return usable;
}
foreach (var reward in rewards)
{
if (reward != null && reward.IsUsable() == true)
{
usable.Add(reward);
}
}
return usable;
}
private void Execute(string url, string action, ulong userId, Action<int, string> callback)
{
webrequest.Enqueue(url, null, (httpCode, response) =>
{
if (httpCode != 200)
{
PrintWarning($"Vote api returned http {httpCode} for action '{action}'");
MessageById(userId, "RequestFailed");
busy.Remove(userId);
return;
}
var code = 0;
if (int.TryParse((response ?? string.Empty).Trim(), out code) == false)
{
PrintWarning($"Failed to parse response from '{response}' for action '{action}'");
MessageById(userId, "RequestFailed");
busy.Remove(userId);
return;
}
callback(code, response);
}, this, Core.Libraries.RequestMethod.GET, null, RequestTimeout);
}
// Puts the vote link in a note so the player can select and copy it,
// chat text cannot be copied out of the game.
private bool GiveVoteNote(BasePlayer player)
{
if (config.giveVoteNote == false || player == null || player.IsConnected == false)
{
return false;
}
var text = string.Format(lang.GetMessage("NoteText", this, player.UserIDString), VotePageUrl());
// Do not fill the inventory up with duplicates on every check.
if (HasVoteNote(player, text) == true)
{
return true;
}
var note = ItemManager.CreateByName(NoteShortname, 1);
if (note == null)
{
PrintWarning($"Could not create a '{NoteShortname}' item, falling back to a chat message");
return false;
}
note.name = lang.GetMessage("NoteName", this, player.UserIDString);
note.text = text;
note.MarkDirty();
player.GiveItem(note, BaseEntity.GiveItemReason.PickedUp);
return true;
}
private static bool HasVoteNote(BasePlayer player, string text)
{
var inventory = player.inventory;
if (inventory == null)
{
return false;
}
return ContainerHasNote(inventory.containerMain, text)
|| ContainerHasNote(inventory.containerBelt, text)
|| ContainerHasNote(inventory.containerWear, text);
}
private static bool ContainerHasNote(ItemContainer container, string text)
{
if (container?.itemList == null)
{
return false;
}
foreach (var item in container.itemList)
{
if (item?.info == null || item.info.shortname != NoteShortname)
{
continue;
}
if (item.text == text)
{
return true;
}
}
return false;
}
private string VotePageUrl()
{
return ServerPageUrl + config.serverId;
}
private string BuildUrl(string action, string steamId)
{
return $"{ApiUrl}?action={action}" +
$"&key={Uri.EscapeDataString(config.apiKey ?? string.Empty)}" +
$"&server={Uri.EscapeDataString(config.serverId ?? string.Empty)}" +
$"&steamid={Uri.EscapeDataString(steamId)}";
}
private void PruneCooldowns()
{
var now = DateTime.UtcNow;
var expired = new List<ulong>();
foreach (var entry in cooldown)
{
if (entry.Value <= now)
{
expired.Add(entry.Key);
}
}
foreach (var userId in expired)
{
cooldown.Remove(userId);
}
}
private bool IsConfigured()
{
return string.IsNullOrEmpty(config.apiKey) == false
&& string.IsNullOrEmpty(config.serverId) == false;
}
private void ValidateConfig()
{
if (IsConfigured() == false)
{
PrintError("'API Key' and/or 'Server Id' are not set, voting is disabled until you fill them in!");
}
if (UsableRewards(config.rewardItems).Count == 0 && UsableRewards(config.rewardCommands).Count == 0)
{
PrintError("No usable rewards configured! Check 'Reward items' and 'Reward Commands'.");
}
foreach (var item in config.rewardItems ?? new RewardItem[0])
{
if (item != null && item.IsUsable() == false)
{
PrintWarning($"Reward item '{item.shortname}' is invalid and will be skipped");
}
}
}
private void Message(BasePlayer player, string key, params object[] args)
{
if (player == null || player.IsConnected == false)
{
return;
}
player.ChatMessage(GetText(player, key, args));
}
// Center screen game tip, harder to miss than a chat line.
private void ShowAlert(BasePlayer player, string key, params object[] args)
{
if (config.showAlert == false || player == null || player.IsConnected == false)
{
return;
}
// The console command takes the text as a single argument,
// quotes and newlines would break it apart.
var text = GetText(player, key, args)
.Replace("\"", "'")
.Replace("\n", " ");
player.SendConsoleCommand("gametip.showgametip", text);
timer.Once(config.alertSeconds, () =>
{
if (player != null && player.IsConnected == true)
{
player.SendConsoleCommand("gametip.hidegametip");
}
});
}
private string GetText(BasePlayer player, string key, params object[] args)
{
var message = lang.GetMessage(key, this, player.UserIDString);
if (args.Length == 0)
{
return message;
}
try
{
return string.Format(message, args);
}
catch (FormatException)
{
// A hand edited lang file can contain stray braces, show it as is
// instead of dropping the message entirely.
PrintWarning($"Message '{key}' has an invalid placeholder, check your lang file");
return message;
}
}
private void MessageById(ulong userId, string key, params object[] args)
{
Message(BasePlayer.FindByID(userId), key, args);
}
#endregion
#region Config
private class ConfigData
{
[JsonProperty("API Key")]
public string apiKey = string.Empty;
[JsonProperty("Server Id")]
public string serverId = string.Empty;
[JsonProperty("Cooldown (seconds)")]
public int cooldownSeconds = 5;
[JsonProperty("Give vote link as note")]
public bool giveVoteNote = true;
[JsonProperty("Show on screen alert")]
public bool showAlert = true;
[JsonProperty("Alert duration (seconds)")]
public float alertSeconds = 8f;
[JsonProperty("Chat Commands")]
public string[] commands =
{
"claim",
"vote",
"votes",
};
[JsonProperty("Reward items")]
public RewardItem[] rewardItems = new[]
{
new RewardItem
{
shortname = "wood",
description = "1000-2000 Wood",
amountMin = 1000,
amountMax = 2000,
},
new RewardItem
{
shortname = "stones",
description = "1000-2000 Stones",
amountMin = 1000,
amountMax = 2000,
},
new RewardItem
{
shortname = "metal.refined",
description = "1000-2000 High Quality Metal",
amountMin = 1000,
amountMax = 2000,
},
};
[JsonProperty("Reward Commands Help")]
public string commandHelp = "use {steamid} to add user steam id\n" +
"Examples:\n" +
"sr add {steamid} 10 - to add ServerRewards points\n" +
"deposit {steamid} 10 - to add Economics points\n";
// Left empty on purpose: placeholder entries would run bogus console
// commands and report them as successful rewards.
[JsonProperty("Reward Commands")]
public RewardCommand[] rewardCommands = new RewardCommand[0];
}
private ConfigData config = new ConfigData();
protected override void LoadConfig()
{
base.LoadConfig();
try
{
config = Config.ReadObject<ConfigData>();
if (config == null)
{
LoadDefaultConfig();
}
}
catch (Exception ex)
{
PrintError($"Configuration file is corrupt! Check your config file at https://jsonlint.com/ ({ex.Message})");
PrintError("Loading default configuration, your config file was left untouched.");
LoadDefaultConfig();
return;
}
if (config.cooldownSeconds < 1)
{
config.cooldownSeconds = 1;
}
if (config.alertSeconds < 1f)
{
config.alertSeconds = 1f;
}
SaveConfig();
}
protected override void LoadDefaultConfig()
{
config = new ConfigData();
}
protected override void SaveConfig()
{
Config.WriteObject(config);
}
#endregion
#region Rewards
private interface IReward
{
bool GiveTo(BasePlayer player);
bool IsUsable();
string Info();
}
private class RewardItem : IReward
{
[JsonProperty("Item Shortname")] public string shortname;
[JsonProperty("Description")] public string description;
[JsonProperty("Item Amount Min")] public int amountMin;
[JsonProperty("Item Amount Max")] public int amountMax;
[JsonProperty("Item Skin id")] public ulong skinId;
public Item GetItem()
{
var def = ItemManager.FindItemDefinition(shortname);
if (def == null)
{
return null;
}
var min = Math.Max(1, Math.Min(amountMin, amountMax));
var max = Math.Max(1, Math.Max(amountMin, amountMax));
// Core.Random.Range is max exclusive, +1 keeps amountMax reachable.
var item = ItemManager.Create(def, Core.Random.Range(min, max + 1));
if (item == null)
{
return null;
}
item.skin = skinId;
return item;
}
public bool GiveTo(BasePlayer player)
{
var item = GetItem();
if (item == null)
{
return false;
}
player.GiveItem(item, BaseEntity.GiveItemReason.PickedUp);
return true;
}
public bool IsUsable()
{
return string.IsNullOrEmpty(shortname) == false
&& ItemManager.FindItemDefinition(shortname) != null;
}
public string Info()
{
return string.IsNullOrEmpty(description) == false
? $"Item: {description}"
: $"Item: {amountMin}-{amountMax}x {shortname}";
}
}
private class RewardCommand : IReward
{
[JsonProperty("Command")] public string command = string.Empty;
[JsonProperty("Description")] public string description = string.Empty;
[JsonProperty("Type")] public string type = string.Empty;
public bool GiveTo(BasePlayer player)
{
if (IsUsable() == false)
{
return false;
}
var line = command;
line = ReplaceToken(line, "{steamid}", player.UserIDString);
line = ReplaceToken(line, "{steam}", player.UserIDString);
line = ReplaceToken(line, "{id}", player.UserIDString);
ConsoleSystem.Run(ConsoleSystem.Option.Server, line);
return true;
}
public bool IsUsable()
{
return string.IsNullOrEmpty(command) == false
&& command.StartsWith("example ", StringComparison.OrdinalIgnoreCase) == false;
}
public string Info()
{
return string.IsNullOrEmpty(type) == false
? $"{type}: {description}"
: description;
}
private static string ReplaceToken(string input, string token, string value)
{
var index = input.IndexOf(token, StringComparison.OrdinalIgnoreCase);
while (index >= 0)
{
input = input.Substring(0, index) + value + input.Substring(index + token.Length);
index = input.IndexOf(token, index + value.Length, StringComparison.OrdinalIgnoreCase);
}
return input;
}
}
#endregion
}
}