/* * <----- 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 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> 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 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 { "PlaytimeCommands", "Excuted commands for {PLAYERNAME} - {PLAYERID}", } }, Rewards = new Dictionary> { { 20, new List { "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 { "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 Players { get; set; } = new Dictionary(); } 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 RewardsReceived { get; set; } = new List(); [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(file); if (raw == null) return; if (raw["Players"] != null) { storedData = raw.ToObject() ?? new PlaytimeData(); if (storedData.Players == null) storedData.Players = new Dictionary(); } 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>() ?? new List() }; 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 { ["Prefix"] = "[PlayTime] ", ["NoPermission"] = "You don't have permission to use this command.", ["Help"] = "Playtime Commands:\n/playtime - view your playtime\n/playtime top - view the top playtimes\n/prc resetrewards [name|id] - reset rewards (all or one player)\n/prc resetplaytime [name|id] - reset playtime (all or one player)\n/prc import - import playtime from PlaytimeTracker", ["Playtime.Self"] = "Your playtime: {0}", ["Playtime.Other"] = "{0} playtime: {1}", ["Playtime.None"] = "No playtime has been stored for you yet.", ["Playtime.Help"] = "See the top playtimes with /playtime top", ["Top.Title"] = "Top Playtimes:", ["Top.Format"] = "\n#{0} {1} - {2}", ["Top.Empty"] = "No playtime has been recorded yet.", ["NoPlayerFound"] = "No player found matching {0}.", ["Reset.Rewards.All"] = "Cleared all players' rewards data.", ["Reset.Rewards.Player"] = "Cleared rewards data for {0}.", ["Reset.Playtime.All"] = "Cleared all players' playtime.", ["Reset.Playtime.Player"] = "Cleared playtime for {0}.", }, 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 grantedThisTick = Pool.Get>(); 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 - 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> top = Pool.Get>>(); foreach (var kvp in storedData.Players) top.Add(new KeyValuePair(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 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(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() ?? 0; string name = prop.Value?["displayName"]?.Value(); 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 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 { message }); } private void SendDiscordMessage(string webhook, string title, List 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 { { "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 Embeds { get; set; } public string ToJson() { return JsonConvert.SerializeObject(this); } } private class Embed { public int color { get; set; } [JsonProperty("fields")] public List Fields { get; set; } = new List(); 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 } }