C#:
/*
* <----- End-User License Agreement ----->
* Copyright © 2024-26 Iftebinjan
* Devoloper: Iftebinjan (Contact: https://discord.gg/HFaGs8YwsH)
*
* You may not copy, modify, merge, publish, distribute, sublicense, or sell copies of This Software without the Developer’s consent
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
* THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS
* BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE
* GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
* LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
* <----- End-User License Agreement ----->
*/
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Oxide.Core;
using Oxide.Core.Plugins;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using Facepunch;
using Oxide.Core.Libraries;
using UnityEngine;
namespace Oxide.Plugins {
[Info("PlaytimeCommands", "Ifte", "2.0.0")]
[Description("Track playtime natively and execute commands on playtime milestones")]
public class PlaytimeCommands : RustPlugin {
#region Vars
private Configuration config;
private Timer RepeatTimer;
private const string AdminPerm = "PlaytimeCommands.admin";
private static double CurrentTime => DateTime.UtcNow.Subtract(new DateTime(1970, 1, 1, 0, 0, 0)).TotalSeconds;
#endregion
#region Config
protected override void LoadConfig() {
base.LoadConfig();
config = Config.ReadObject<Configuration>();
Configuration defaults = GetBaseConfig();
if (config.interval <= 0) config.interval = defaults.interval;
if (config.TopCount <= 0) config.TopCount = defaults.TopCount;
if (config.Rewards == null) config.Rewards = defaults.Rewards;
if (config.webhook == null) config.webhook = defaults.webhook;
else if (config.webhook.DiscordMessages == null) config.webhook.DiscordMessages = defaults.webhook.DiscordMessages;
config.Version = Version;
Config.WriteObject(config, true);
}
protected override void SaveConfig() => Config.WriteObject(config, true);
protected override void LoadDefaultConfig() => config = GetBaseConfig();
private class Configuration {
[JsonProperty("Check how often it checks for playtime and execute commands?(In Seconds)")]
public int interval {
get;
set;
}
[JsonProperty("Number of players to show in the /playtime top list")]
public int TopCount {
get;
set;
}
[JsonProperty("Discord Webhook Settings")]
public DiscordSetting webhook {
get;
set;
}
[JsonProperty("Reset data on new wipe")]
public bool resetonwipe {
get;
set;
}
[JsonProperty("Execute command upon playtime Minutes completation")]
public Dictionary<int, List<string>> Rewards {
get;
set;
}
public VersionNumber Version {
get;
set;
}
}
private class DiscordSetting {
[JsonProperty("Enable discord notify")]
public bool enableDiscord {
get;
set;
}
[JsonProperty("Webhook URL")]
public string webHookURL {
get;
set;
}
[JsonProperty("Message")]
public List<string> DiscordMessages {
get;
set;
}
}
private Configuration GetBaseConfig() {
return new Configuration() {
interval = 600, TopCount = 10, resetonwipe = false, webhook = new DiscordSetting() {
enableDiscord = false, webHookURL = string.Empty, DiscordMessages = new List<string> {
"PlaytimeCommands", "Excuted commands for {PLAYERNAME} - {PLAYERID}",
}
}
, Rewards = new Dictionary<int, List<string>> {
{
20, new List<string> {
"oxide.usergroup add {UserID} chads", "chat user add {UserID} chads", "msg: You have recevied chads role for playtime.", "broadcast: {Name} has recevied chad role for playtime"
}
}
, {
30, new List<string> {
"sr add {Name} 1000", "sr take {UserID} 100", "oxide.usergroup add {UserID} veteran"
}
}
,
}
, Version = Version
}
;
}
#endregion
#region Data Management
private PlaytimeData storedData;
private class PlaytimeData {
[JsonProperty("Players")]
public Dictionary<string, PlayerInfo> Players {
get;
set;
}
= new Dictionary<string, PlayerInfo>();
}
private class PlayerInfo {
[JsonProperty("Display Name")]
public string DisplayName {
get;
set;
}
[JsonProperty("Total Playtime (seconds)")]
public double Playtime {
get;
set;
}
[JsonProperty("Rewards Received (minute thresholds)")]
public List<int> RewardsReceived {
get;
set;
}
= new List<int>();
[JsonIgnore]
public double SessionStart {
get;
set;
}
}
private void LoadData() {
storedData = new PlaytimeData();
string file = $"{Name}/PlayerRewards";
if (!Interface.Oxide.DataFileSystem.ExistsDatafile(file)) return;
JObject raw = Interface.Oxide.DataFileSystem.ReadObject<JObject>(file);
if (raw == null) return;
if (raw["Players"] != null) {
storedData = raw.ToObject<PlaytimeData>() ?? new PlaytimeData();
if (storedData.Players == null) storedData.Players = new Dictionary<string, PlayerInfo>();
}
else {
MigrateOldRewardData(raw);
}
}
private void MigrateOldRewardData(JObject raw) {
int migrated = 0;
foreach (JProperty prop in raw.Properties()) {
if (!(prop.Value is JArray arr)) continue;
storedData.Players[prop.Name] = new PlayerInfo {
RewardsReceived = arr.ToObject<List<int>>() ?? new List<int>()
}
;
migrated++;
}
if (migrated > 0) {
SaveData();
Puts($"Migrated {migrated} player(s) from the old PlayerRewards data format to the new format.");
}
}
private void SaveData() => Interface.Oxide.DataFileSystem.WriteObject($"{Name}/PlayerRewards", storedData);
private PlayerInfo GetPlayerInfo(BasePlayer player) {
if (!storedData.Players.TryGetValue(player.UserIDString, out PlayerInfo info)) {
info = new PlayerInfo {
DisplayName = player.displayName
}
;
storedData.Players[player.UserIDString] = info;
}
return info;
}
#endregion
#region Localization
protected override void LoadDefaultMessages() {
lang.RegisterMessages(new Dictionary<string, string> {
["Prefix"]
= "<color=#45b6fe>[PlayTime]</color> ", ["NoPermission"] = "<color=#ff6b6b>You don't have permission to use this command.</color>", ["Help"] = "<color=#45b6fe>Playtime Commands:</color>\n<color=#ffd479>/playtime</color> - view your playtime\n<color=#ffd479>/playtime top</color> - view the top playtimes\n<color=#ffd479>/prc resetrewards [name|id]</color> - reset rewards (all or one player)\n<color=#ffd479>/prc resetplaytime [name|id]</color> - reset playtime (all or one player)\n<color=#ffd479>/prc import</color> - import playtime from PlaytimeTracker", ["Playtime.Self"] = "Your playtime: <color=#ffd479>{0}</color>", ["Playtime.Other"] = "<color=#a1ff46>{0}</color> playtime: <color=#ffd479>{1}</color>", ["Playtime.None"] = "No playtime has been stored for you yet.", ["Playtime.Help"] = "See the top playtimes with <color=#a1ff46>/playtime top</color>", ["Top.Title"] = "<color=#45b6fe>Top Playtimes:</color>", ["Top.Format"] = "\n<color=#ffd479>#{0}</color> <color=#a1ff46>{1}</color> - <color=#ffd479>{2}</color>", ["Top.Empty"] = "No playtime has been recorded yet.", ["NoPlayerFound"] = "No player found matching <color=#a1ff46>{0}</color>.", ["Reset.Rewards.All"] = "Cleared <color=#ffd479>all</color> players' rewards data.", ["Reset.Rewards.Player"] = "Cleared rewards data for <color=#a1ff46>{0}</color>.", ["Reset.Playtime.All"] = "Cleared <color=#ffd479>all</color> players' playtime.", ["Reset.Playtime.Player"] = "Cleared playtime for <color=#a1ff46>{0}</color>.",
}
, this);
}
private void Message(BasePlayer player, string key, params object[] args) {
string prefix = lang.GetMessage("Prefix", this, player.UserIDString);
string message = lang.GetMessage(key, this, player.UserIDString);
if (args != null && args.Length > 0) message = string.Format(message, args);
player.ChatMessage(prefix + message);
}
#endregion
#region Playtime Tracking
private void StartSession(BasePlayer player) {
PlayerInfo info = GetPlayerInfo(player);
info.DisplayName = player.displayName;
info.SessionStart = CurrentTime;
}
private void EndSession(BasePlayer player) {
if (storedData.Players.TryGetValue(player.UserIDString, out PlayerInfo info) && info.SessionStart > 0) {
info.Playtime += CurrentTime - info.SessionStart;
info.SessionStart = 0;
}
}
private void FlushSession(PlayerInfo info) {
if (info.SessionStart > 0) {
double now = CurrentTime;
info.Playtime += now - info.SessionStart;
info.SessionStart = now;
}
}
private double GetPlaytimeSeconds(string id) {
if (!storedData.Players.TryGetValue(id, out PlayerInfo info)) return 0;
double time = info.Playtime;
if (info.SessionStart > 0) time += CurrentTime - info.SessionStart;
return time;
}
// API
[HookMethod("GetPlaytime")]
private object GetPlayTime(string id) {
double time = GetPlaytimeSeconds(id);
return time <= 0 ? null : (object)time;
}
#endregion
#region Hooks
private void Init() {
permission.RegisterPermission(AdminPerm, this);
LoadData();
}
private void OnServerInitialized() {
foreach (BasePlayer player in BasePlayer.activePlayerList) StartSession(player);
if (config.interval > 0) RepeatTimer = timer.Every(config.interval, ProcessPlaytime);
}
private void Unload() {
RepeatTimer?.Destroy();
foreach (BasePlayer player in BasePlayer.activePlayerList) EndSession(player);
SaveData();
}
private void OnPlayerConnected(BasePlayer player) {
if (player == null) return;
StartSession(player);
}
private void OnPlayerDisconnected(BasePlayer player, string reason) {
if (player == null) return;
EndSession(player);
SaveData();
}
private void OnServerSave() {
foreach (BasePlayer player in BasePlayer.activePlayerList) {
if (storedData.Players.TryGetValue(player.UserIDString, out PlayerInfo info)) FlushSession(info);
}
SaveData();
}
private void OnNewSave(string filename) {
if (!config.resetonwipe) return;
storedData.Players.Clear();
foreach (BasePlayer player in BasePlayer.activePlayerList) StartSession(player);
SaveData();
Puts("Playtime data cleared for new wipe.");
}
#endregion
#region Main
private void ProcessPlaytime() {
if (config == null || config.Rewards == null || config.Rewards.Count == 0) return;
foreach (BasePlayer player in BasePlayer.activePlayerList) {
PlayerInfo info = GetPlayerInfo(player);
FlushSession(info);
info.DisplayName = player.displayName;
double playtimeMinutes = info.Playtime / 60.0;
List<int> grantedThisTick = Pool.Get<List<int>>();
foreach (var reward in config.Rewards) {
int threshold = reward.Key;
// threshold in minutes
if (playtimeMinutes >= threshold && !info.RewardsReceived.Contains(threshold)) {
foreach (string command in reward.Value) ExecuteCommand(command, player);
info.RewardsReceived.Add(threshold);
grantedThisTick.Add(threshold);
}
}
// One notification per player per tick
if (grantedThisTick.Count > 0) SendDHook(player, grantedThisTick);
Pool.FreeUnmanaged(ref grantedThisTick);
}
SaveData();
}
private void ExecuteCommand(string command, BasePlayer player) {
if (string.IsNullOrWhiteSpace(command)) return;
// Check for {UserID}, {Name} - tag matching is case-insensitive and tolerant of spacing
string trimmed = command.TrimStart();
if (trimmed.StartsWith("broadcast:", StringComparison.OrdinalIgnoreCase)) {
string message = trimmed.Substring("broadcast:".Length).TrimStart();
BroadcastMessage(FormatPlaceholders(message, player));
}
else if (trimmed.StartsWith("msg:", StringComparison.OrdinalIgnoreCase)) {
string message = trimmed.Substring("msg:".Length).TrimStart();
CM(player, FormatPlaceholders(message, player));
}
else Server.Command(FormatPlaceholders(command, player));
}
private string FormatPlaceholders(string input, BasePlayer player) {
return input.Replace("{UserID}", player.UserIDString).Replace("{Name}", player.displayName);
}
private void BroadcastMessage(string message) {
foreach (BasePlayer player in BasePlayer.activePlayerList) CM(player, message);
}
private static void CM(BasePlayer player, string message) => player.ChatMessage(message);
#endregion
#region Commands
[ChatCommand("playtime")]
private void CmdPlaytime(BasePlayer player, string cmd, string[] args) {
if (player == null) return;
if (args.Length > 0) {
// /playtime top - leaderboard
if (args[0].Equals("top", StringComparison.OrdinalIgnoreCase)) {
ShowTop(player);
return;
}
// /playtime <name|id> - admins can look up another player's playtime
if (permission.UserHasPermission(player.UserIDString, AdminPerm) || player.IsAdmin) {
string targetId = ResolveUserId(args[0]);
if (targetId == null || !storedData.Players.TryGetValue(targetId, out PlayerInfo target)) {
Message(player, "NoPlayerFound", args[0]);
return;
}
Message(player, "Playtime.Other", target.DisplayName ?? targetId, FormatTime(GetPlaytimeSeconds(targetId)));
return;
}
}
double time = GetPlaytimeSeconds(player.UserIDString);
if (time <= 0) {
Message(player, "Playtime.None");
Message(player, "Playtime.Help");
return;
}
Message(player, "Playtime.Self", FormatTime(time));
Message(player, "Playtime.Help");
}
private void ShowTop(BasePlayer player) {
if (storedData.Players.Count == 0) {
Message(player, "Top.Empty");
return;
}
List<KeyValuePair<string, double>> top = Pool.Get<List<KeyValuePair<string, double>>>();
foreach (var kvp in storedData.Players) top.Add(new KeyValuePair<string, double>(kvp.Value.DisplayName ?? kvp.Key, GetPlaytimeSeconds(kvp.Key)));
top.Sort((a, b) => b.Value.CompareTo(a.Value));
int count = Math.Min(config.TopCount > 0 ? config.TopCount : 10, top.Count);
string result = lang.GetMessage("Top.Title", this, player.UserIDString);
string format = lang.GetMessage("Top.Format", this, player.UserIDString);
for (int i = 0; i < count; i++) result += string.Format(format, i + 1, top[i].Key, FormatTime(top[i].Value));
Pool.FreeUnmanaged(ref top);
player.ChatMessage(lang.GetMessage("Prefix", this, player.UserIDString) + result);
}
[ChatCommand("prc")]
private void CMDAdmin(BasePlayer player, string cmd, string[] args) {
if (player == null) return;
if (!permission.UserHasPermission(player.UserIDString, AdminPerm) && !player.IsAdmin) {
Message(player, "NoPermission");
return;
}
if (args.Length == 0) {
Message(player, "Help");
return;
}
string arg1 = args.Length >= 2 ? args[1] : null;
switch (args[0].ToLower()) {
case "resetrewards" : // /prc resetrewards [name|id]
switch (ResetRewards(arg1, out string rName)) {
case ResetResult.NotFound : Message(player, "NoPlayerFound", arg1);
break;
case ResetResult.Player : Message(player, "Reset.Rewards.Player", rName);
break;
case ResetResult.All : Message(player, "Reset.Rewards.All");
break;
}
return;
case "resetplaytime" : // /prc resetplaytime [name|id]
switch (ResetPlaytime(arg1, out string pName)) {
case ResetResult.NotFound : Message(player, "NoPlayerFound", arg1);
break;
case ResetResult.Player : Message(player, "Reset.Playtime.Player", pName);
break;
case ResetResult.All : Message(player, "Reset.Playtime.All");
break;
}
return;
case "import" : // /prc import - pull playtime from the PlaytimeTracker plugin
ImportFromPlaytimeTracker(msg => player.ChatMessage(lang.GetMessage("Prefix", this, player.UserIDString) + msg));
return;
default : Message(player, "Help");
return;
}
}
[ConsoleCommand("prc")]
private void CMDAdminConsole(ConsoleSystem.Arg arg) {
BasePlayer player = arg.Player();
if (player != null && !permission.UserHasPermission(player.UserIDString, AdminPerm) && !player.IsAdmin) {
arg.ReplyWith("No permission.");
return;
}
if (arg.Args == null || arg.Args.Length == 0) {
arg.ReplyWith("Usage: prc resetrewards [name|id] | prc resetplaytime [name|id] | prc import");
return;
}
string arg1 = arg.Args.Length >= 2 ? arg.Args[1].ToString() : null;
switch (arg.Args[0].ToString().ToLower()) {
case "resetrewards" : switch (ResetRewards(arg1, out string rName)) {
case ResetResult.NotFound : arg.ReplyWith($"No player found matching {arg1}");
break;
case ResetResult.Player : arg.ReplyWith($"Cleared rewards data for {rName}");
break;
case ResetResult.All : arg.ReplyWith("Cleared all players' rewards data.");
break;
}
return;
case "resetplaytime" : switch (ResetPlaytime(arg1, out string pName)) {
case ResetResult.NotFound : arg.ReplyWith($"No player found matching {arg1}");
break;
case ResetResult.Player : arg.ReplyWith($"Cleared playtime for {pName}");
break;
case ResetResult.All : arg.ReplyWith("Cleared all players' playtime.");
break;
}
return;
case "import" : // prc import - pull playtime from the PlaytimeTracker plugin's data file
ImportFromPlaytimeTracker(msg => arg.ReplyWith(msg));
return;
}
}
#endregion
#region Helpers
private enum ResetResult {
NotFound, Player, All
}
private bool IsOnline(string id) => BasePlayer.activePlayerList.Any(p => p.UserIDString == id);
private ResetResult ResetRewards(string nameOrId, out string name) {
name = null;
if (!string.IsNullOrEmpty(nameOrId)) {
string targetId = ResolveUserId(nameOrId);
if (targetId == null || !storedData.Players.TryGetValue(targetId, out PlayerInfo target)) return ResetResult.NotFound;
name = target.DisplayName ?? targetId;
target.RewardsReceived.Clear();
SaveData();
return ResetResult.Player;
}
foreach (var kvp in storedData.Players) kvp.Value.RewardsReceived.Clear();
SaveData();
return ResetResult.All;
}
private ResetResult ResetPlaytime(string nameOrId, out string name) {
name = null;
double now = CurrentTime;
if (!string.IsNullOrEmpty(nameOrId)) {
string targetId = ResolveUserId(nameOrId);
if (targetId == null || !storedData.Players.TryGetValue(targetId, out PlayerInfo target)) return ResetResult.NotFound;
name = target.DisplayName ?? targetId;
target.Playtime = 0;
target.SessionStart = IsOnline(targetId) ? now : 0;
SaveData();
return ResetResult.Player;
}
foreach (var kvp in storedData.Players) {
kvp.Value.Playtime = 0;
kvp.Value.SessionStart = 0;
}
foreach (BasePlayer p in BasePlayer.activePlayerList) {
if (storedData.Players.TryGetValue(p.UserIDString, out PlayerInfo info)) info.SessionStart = now;
}
SaveData();
return ResetResult.All;
}
private string ResolveUserId(string arg) {
if (string.IsNullOrEmpty(arg)) return null;
if (arg.Length == 17 && ulong.TryParse(arg, out _)) return arg;
BasePlayer active = BasePlayer.activePlayerList .FirstOrDefault(p => p.displayName.IndexOf(arg, StringComparison.OrdinalIgnoreCase) >= 0);
if (active != null) return active.UserIDString;
foreach (var kvp in storedData.Players) {
if (!string.IsNullOrEmpty(kvp.Value.DisplayName) && kvp.Value.DisplayName.IndexOf(arg, StringComparison.OrdinalIgnoreCase) >= 0) return kvp.Key;
}
return null;
}
private string FormatTime(double seconds) {
TimeSpan span = TimeSpan.FromSeconds(seconds);
int hours = (int)span.TotalHours;
return $"{hours:00}h {span.Minutes:00}m {span.Seconds:00}s";
}
// Imports playtime (in seconds) from the PlaytimeTracker plugin's data file
private void ImportFromPlaytimeTracker(Action<string> reply) {
const string trackerFile = "PlaytimeTracker/user_data";
if (!Interface.Oxide.DataFileSystem.ExistsDatafile(trackerFile)) {
reply("No PlaytimeTracker data found at oxide/data/PlaytimeTracker/user_data.json");
return;
}
JObject raw = Interface.Oxide.DataFileSystem.ReadObject<JObject>(trackerFile);
JObject users = raw?["_userData"] as JObject;
if (users == null || users.Count == 0) {
reply("PlaytimeTracker data is empty or could not be read.");
return;
}
foreach (BasePlayer p in BasePlayer.activePlayerList) {
if (storedData.Players.TryGetValue(p.UserIDString, out PlayerInfo pi)) FlushSession(pi);
}
int imported = 0;
foreach (JProperty prop in users.Properties()) {
string id = prop.Name;
double playtime = prop.Value?["playtime"]?.Value<double>() ?? 0;
string name = prop.Value?["displayName"]?.Value<string>();
if (playtime <= 0) continue;
if (!storedData.Players.TryGetValue(id, out PlayerInfo info)) {
info = new PlayerInfo();
storedData.Players[id] = info;
}
if (!string.IsNullOrEmpty(name)) info.DisplayName = name;
if (playtime > info.Playtime) info.Playtime = playtime;
imported++;
}
foreach (BasePlayer p in BasePlayer.activePlayerList) StartSession(p);
SaveData();
reply($"Imported playtime for {imported} player(s) from PlaytimeTracker.");
}
#endregion
#region Discord Intregration
private void SendDHook(BasePlayer player, List<int> playtimes) {
if (!config.webhook.enableDiscord || string.IsNullOrEmpty(config.webhook.webHookURL)) return;
string message = string.Join("\n", config.webhook.DiscordMessages);
message = message .Replace("{PLAYERNAME}", player.displayName) .Replace("{PLAYERID}", player.UserIDString) .Replace("{PLAYTIME}", string.Join(", ", playtimes));
SendDiscordMessage(config.webhook.webHookURL, "", new List<string> {
message
}
);
}
private void SendDiscordMessage(string webhook, string title, List<string> embeds, bool inline = false) {
Embed embed = new Embed();
foreach (var item in embeds) {
embed.AddField(title, item, inline, 3066993);
}
webrequest.Enqueue(webhook, new DiscordMessage(string.Empty, embed).ToJson(), (code, response) => {
}
, this, RequestMethod.POST, new Dictionary<string, string> {
{
"Content-Type", "application/json"
}
}
);
}
private class DiscordMessage {
public DiscordMessage(string content, params Embed[] embeds) {
Content = content;
Embeds = embeds.ToList();
}
[JsonProperty("content")]
public string Content {
get;
set;
}
[JsonProperty("embeds")]
public List<Embed> Embeds {
get;
set;
}
public string ToJson() {
return JsonConvert.SerializeObject(this);
}
}
private class Embed {
public int color {
get;
set;
}
[JsonProperty("fields")]
public List<Field> Fields {
get;
set;
}
= new List<Field>();
public Embed AddField(string name, string value, bool inline, int colors) {
Fields.Add(new Field(name, Regex.Replace(value, "<.*?>", string.Empty), inline));
color = colors;
return this;
}
}
private class Field {
public Field(string name, string value, bool inline) {
Name = name;
Value = value;
Inline = inline;
}
[JsonProperty("name")]
public string Name {
get;
set;
}
[JsonProperty("value")]
public string Value {
get;
set;
}
[JsonProperty("inline")]
public bool Inline {
get;
set;
}
}
#endregion
}
}