C#:
/*
* Money Time was originally created by Spiritwind and maintained by Wulf.
* Original C# version copyright (c) 2020 Wulf.
* Fixes and maintenance changes copyright (c) 2026 SeesAll.
* Licensed under the MIT License; retain the original copyright and license notice.
* Project notes: https://github.com/SeesAll/MoneyTime-Fixed
*/
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Oxide.Core;
using Oxide.Core.Libraries.Covalence;
using Oxide.Core.Plugins;
using UnityEngine;
namespace Oxide.Plugins
{
[Info("Money Time", "Wulf, SeesAll", "2.4.0")]
[Description("Pays active players with Economics money or Server Rewards points for playing")]
public class MoneyTime : CovalencePlugin
{
private const string PermissionPrefix = "moneytime.";
private const string AdminPermission = "moneytime.admin";
private const string LegacyMultiplierKey = "Permission-based mulitipliers";
private const string MultiplierKey = "Permission-based multipliers";
private const float HeartbeatInterval = 5f;
#region Configuration
private Configuration _config;
private class Configuration
{
[JsonProperty("Currency provider (Economics or ServerRewards)")]
public string CurrencyProvider = "Economics";
[JsonProperty("Enable AFK API plugin support")]
public bool AfkApi;
[JsonProperty("Base payout amount")]
public double BasePayout = 100d;
[JsonProperty("Payout interval (seconds)")]
public int PayoutInterval = 600;
[JsonProperty("Time alive bonus")]
public bool TimeAliveBonus;
[JsonProperty("Time alive multiplier")]
public double TimeAliveMultiplier;
[JsonProperty("Allow permission-based multipliers to stack")]
public bool StackMultipliers;
[JsonProperty("Stacked multiplier mode (Additive or Multiplicative)")]
public string StackedMultiplierMode = "Additive";
[JsonProperty("New player welcome bonus")]
public double WelcomeBonus = 500d;
[JsonProperty("Notify players of successful payouts")]
public bool NotifySuccessfulPayouts = true;
[JsonProperty("Notify players of missed AFK payouts")]
public bool NotifyMissedPayouts = true;
[JsonProperty("Reset player data on a new save")]
public bool ResetDataOnNewSave;
[JsonProperty(MultiplierKey, ObjectCreationHandling = ObjectCreationHandling.Replace)]
public SortedDictionary<string, double> PermissionMultipliers = new SortedDictionary<string, double>
{
["vip"] = 5d,
["donor"] = 2.5d
};
}
protected override void LoadDefaultConfig()
{
_config = new Configuration();
}
protected override void LoadConfig()
{
base.LoadConfig();
try
{
JObject rawConfig = Config.ReadObject<JObject>() ?? new JObject();
bool changed = MigrateLegacyConfiguration(rawConfig);
_config = rawConfig.ToObject<Configuration>() ?? new Configuration();
changed |= ValidateConfiguration();
HashSet<string> actualKeys = new HashSet<string>(rawConfig.Properties().Select(x => x.Name));
HashSet<string> expectedKeys = new HashSet<string>(JObject.FromObject(_config).Properties().Select(x => x.Name));
changed |= !actualKeys.SetEquals(expectedKeys);
if (changed)
{
LogWarning("Configuration was migrated or repaired; saving the normalized configuration");
SaveConfig();
}
}
catch (Exception exception)
{
LogError($"Configuration file {Name}.json is invalid ({exception.Message}); replacing it with defaults");
LoadDefaultConfig();
ValidateConfiguration();
SaveConfig();
}
}
protected override void SaveConfig()
{
Config.WriteObject(_config, true);
}
private bool MigrateLegacyConfiguration(JObject rawConfig)
{
bool changed = false;
JToken legacyMultipliers;
if (rawConfig.TryGetValue(LegacyMultiplierKey, out legacyMultipliers))
{
if (rawConfig.Property(MultiplierKey) == null) rawConfig[MultiplierKey] = legacyMultipliers;
rawConfig.Remove(LegacyMultiplierKey);
changed = true;
}
JToken economicsToken;
JToken rewardsToken;
bool hadEconomics = rawConfig.TryGetValue("Enable Economics as default currency", out economicsToken);
bool hadRewards = rawConfig.TryGetValue("Enable Server Rewards as default currency", out rewardsToken);
if (hadEconomics || hadRewards)
{
bool economics = hadEconomics && economicsToken.Type == JTokenType.Boolean && economicsToken.Value<bool>();
bool rewards = hadRewards && rewardsToken.Type == JTokenType.Boolean && rewardsToken.Value<bool>();
rawConfig["Currency provider (Economics or ServerRewards)"] = rewards && !economics ? "ServerRewards" : "Economics";
rawConfig.Remove("Enable Economics as default currency");
rawConfig.Remove("Enable Server Rewards as default currency");
changed = true;
}
return changed;
}
private bool ValidateConfiguration()
{
bool changed = false;
string provider = (_config.CurrencyProvider ?? string.Empty).Replace(" ", string.Empty);
if (provider.Equals("Economics", StringComparison.OrdinalIgnoreCase))
{
changed |= _config.CurrencyProvider != "Economics";
_config.CurrencyProvider = "Economics";
}
else if (provider.Equals("ServerRewards", StringComparison.OrdinalIgnoreCase))
{
changed |= _config.CurrencyProvider != "ServerRewards";
_config.CurrencyProvider = "ServerRewards";
}
else
{
LogWarning($"Unknown currency provider '{_config.CurrencyProvider}'; using Economics");
_config.CurrencyProvider = "Economics";
changed = true;
}
if (!IsFinite(_config.BasePayout) || _config.BasePayout <= 0d)
{
LogWarning("Base payout amount must be greater than zero; using 100");
_config.BasePayout = 100d;
changed = true;
}
if (_config.PayoutInterval < 1)
{
LogWarning("Payout interval must be at least one second; using 600");
_config.PayoutInterval = 600;
changed = true;
}
if (!IsFinite(_config.TimeAliveMultiplier) || _config.TimeAliveMultiplier < 0d)
{
LogWarning("Time alive multiplier cannot be negative; using zero");
_config.TimeAliveMultiplier = 0d;
changed = true;
}
if (!IsFinite(_config.WelcomeBonus) || _config.WelcomeBonus < 0d)
{
LogWarning("Welcome bonus cannot be negative; using zero");
_config.WelcomeBonus = 0d;
changed = true;
}
string stackMode = _config.StackedMultiplierMode ?? string.Empty;
if (stackMode.Equals("Additive", StringComparison.OrdinalIgnoreCase))
{
changed |= stackMode != "Additive";
_config.StackedMultiplierMode = "Additive";
}
else if (stackMode.Equals("Multiplicative", StringComparison.OrdinalIgnoreCase))
{
changed |= stackMode != "Multiplicative";
_config.StackedMultiplierMode = "Multiplicative";
}
else
{
LogWarning($"Unknown stacked multiplier mode '{stackMode}'; using Additive");
_config.StackedMultiplierMode = "Additive";
changed = true;
}
if (_config.PermissionMultipliers == null)
{
_config.PermissionMultipliers = new SortedDictionary<string, double>();
changed = true;
}
SortedDictionary<string, double> valid = new SortedDictionary<string, double>();
foreach (KeyValuePair<string, double> entry in _config.PermissionMultipliers)
{
string name = (entry.Key ?? string.Empty).Trim().ToLowerInvariant();
if (string.IsNullOrEmpty(name) || name.Any(char.IsWhiteSpace) || !IsFinite(entry.Value) || entry.Value <= 0d)
{
LogWarning($"Ignoring invalid permission multiplier '{entry.Key}'");
changed = true;
continue;
}
valid[name] = entry.Value;
changed |= name != entry.Key;
}
_config.PermissionMultipliers = valid;
return changed;
}
private static bool IsFinite(double value)
{
return !double.IsNaN(value) && !double.IsInfinity(value);
}
#endregion
#region Localization
protected override void LoadDefaultMessages()
{
lang.RegisterMessages(new Dictionary<string, string>
{
["MissedPayout"] = "You were inactive and missed this payout.",
["ReceivedForPlaying"] = "You received $payout.amount for actively playing.",
["ReceivedForTimeAlive"] = "You received $payout.amount for staying alive for $time.alive.",
["ReceivedWelcomeBonus"] = "You received $payout.amount as a welcome bonus.",
["NoPermission"] = "You do not have permission to use this command.",
["AdminUsage"] = "Usage: moneytime status | moneytime resetwelcome <Steam ID|all>",
["WelcomeResetAll"] = "Welcome bonus eligibility was reset for all stored players.",
["WelcomeResetPlayer"] = "Welcome bonus eligibility was reset for $player.id.",
["Status"] = "Provider: $provider | Base payout: $amount | Interval: $interval seconds | Your multiplier: $multiplier"
}, this);
}
#endregion
#region Data Storage
private StoredData _storedData;
private class StoredData
{
[JsonProperty("Data version")]
public int Version = 2;
[JsonProperty("Players")]
public Dictionary<string, PlayerInfo> Players = new Dictionary<string, PlayerInfo>();
}
private class PlayerInfo
{
// Keep the legacy field name so existing data remains readable.
[JsonProperty("WelcomeBonus")]
public bool WelcomeBonus;
[JsonProperty("Pending time alive bonus")]
public double PendingAliveBonus;
[JsonProperty("Pending time alive seconds")]
public double PendingAliveSeconds;
}
private void LoadData()
{
try
{
_storedData = Interface.Oxide.DataFileSystem.ReadObject<StoredData>(Name) ?? new StoredData();
if (_storedData.Players == null) _storedData.Players = new Dictionary<string, PlayerInfo>();
_storedData.Version = 2;
}
catch (Exception exception)
{
LogError($"Data file {Name}.json could not be read ({exception.Message}); starting with fresh data");
_storedData = new StoredData();
}
}
private void SaveData()
{
if (_storedData != null) Interface.Oxide.DataFileSystem.WriteObject(Name, _storedData);
}
private PlayerInfo GetPlayerInfo(string playerId)
{
PlayerInfo info;
if (!_storedData.Players.TryGetValue(playerId, out info) || info == null)
{
info = new PlayerInfo();
_storedData.Players[playerId] = info;
}
return info;
}
private void OnServerSave()
{
SaveData();
}
private void OnNewSave(string filename)
{
if (!_config.ResetDataOnNewSave) return;
_storedData = new StoredData();
SaveData();
Log("Player reward data was reset for the new save");
}
#endregion
#region Initialization and Scheduling
[PluginReference]
private Plugin Economics, ServerRewards, AFKAPI;
private readonly Dictionary<string, PayoutState> _payouts = new Dictionary<string, PayoutState>();
private readonly Dictionary<string, float> _lifeStartedAt = new Dictionary<string, float>();
private readonly Dictionary<string, double> _permissionMultipliers = new Dictionary<string, double>();
private readonly HashSet<string> _loggedWarnings = new HashSet<string>();
private class PayoutState
{
public double Amount;
public float DueAt;
public float PendingRetryAt;
}
private enum PayoutResult
{
Success,
SkippedAfk,
Failed
}
private void Init()
{
permission.RegisterPermission(AdminPermission, this);
foreach (KeyValuePair<string, double> entry in _config.PermissionMultipliers)
{
string permissionName = PermissionPrefix + entry.Key;
_permissionMultipliers[permissionName] = entry.Value;
permission.RegisterPermission(permissionName, this);
Log($"Registered permission '{permissionName}' with a {FormatAmount(entry.Value)}x multiplier");
}
LoadData();
if (!_config.TimeAliveBonus)
{
Unsubscribe(nameof(OnPlayerDeath));
Unsubscribe(nameof(OnUserRespawned));
}
}
private void OnServerInitialized()
{
foreach (IPlayer player in players.Connected) InitializePlayer(player, true);
float heartbeat = Math.Min(HeartbeatInterval, Math.Max(1f, _config.PayoutInterval));
timer.Every(heartbeat, ProcessPayouts);
ValidateDependencies();
}
private void Unload()
{
SaveData();
_permissionMultipliers.Clear();
_payouts.Clear();
_lifeStartedAt.Clear();
_loggedWarnings.Clear();
}
private void InitializePlayer(IPlayer player, bool resetSchedule)
{
if (player == null || !player.Id.IsSteamId()) return;
GetPlayerInfo(player.Id);
double appliedMultiplier;
double amount = CalculatePayout(player, out appliedMultiplier);
PayoutState state;
if (!_payouts.TryGetValue(player.Id, out state))
{
state = new PayoutState();
_payouts[player.Id] = state;
resetSchedule = true;
state.PendingRetryAt = Time.realtimeSinceStartup;
}
state.Amount = amount;
if (resetSchedule) state.DueAt = Time.realtimeSinceStartup + _config.PayoutInterval;
if (_config.TimeAliveBonus && !_lifeStartedAt.ContainsKey(player.Id))
_lifeStartedAt[player.Id] = Time.realtimeSinceStartup;
}
private double CalculatePayout(IPlayer player, out double appliedMultiplier)
{
List<double> multipliers = new List<double>();
foreach (KeyValuePair<string, double> entry in _permissionMultipliers)
if (player.HasPermission(entry.Key)) multipliers.Add(entry.Value);
appliedMultiplier = 1d;
if (multipliers.Count == 0) return _config.BasePayout;
if (!_config.StackMultipliers) appliedMultiplier = multipliers.Max();
else if (_config.StackedMultiplierMode == "Multiplicative")
foreach (double multiplier in multipliers) appliedMultiplier *= multiplier;
else appliedMultiplier = multipliers.Sum();
return _config.BasePayout * appliedMultiplier;
}
private void ProcessPayouts()
{
float now = Time.realtimeSinceStartup;
foreach (IPlayer player in players.Connected)
{
if (!player.Id.IsSteamId()) continue;
PayoutState state;
if (!_payouts.TryGetValue(player.Id, out state))
{
InitializePlayer(player, true);
state = _payouts[player.Id];
}
if (state.PendingRetryAt <= now)
{
TryPendingRewards(player);
state.PendingRetryAt = now + Math.Min(30f, _config.PayoutInterval);
}
if (state.DueAt > now) continue;
PayoutResult result = TryPayout(player, state.Amount, "ReceivedForPlaying", true, null);
state.DueAt = result == PayoutResult.Failed
? now + Math.Min(30f, _config.PayoutInterval)
: now + _config.PayoutInterval;
}
}
private void ValidateDependencies()
{
if (_config.CurrencyProvider == "Economics" && (Economics == null || !Economics.IsLoaded))
WarnOnce("economics-missing", "Economics is configured but is not loaded; rewards will remain pending or retry");
else if (_config.CurrencyProvider == "ServerRewards" && (ServerRewards == null || !ServerRewards.IsLoaded))
WarnOnce("serverrewards-missing", "ServerRewards is configured but is not loaded; rewards will remain pending or retry");
if (_config.AfkApi && (AFKAPI == null || !AFKAPI.IsLoaded))
WarnOnce("afkapi-missing", "AFK API support is enabled but AFKAPI is not loaded; payouts will continue without AFK filtering");
}
#endregion
#region Permission Updates
private void OnGroupPermissionGranted(string groupName, string permissionName)
{
if (_permissionMultipliers.ContainsKey(permissionName)) RecalculateConnectedPlayers();
}
private void OnGroupPermissionRevoked(string groupName, string permissionName)
{
if (_permissionMultipliers.ContainsKey(permissionName)) RecalculateConnectedPlayers();
}
private void OnUserPermissionGranted(string playerId, string permissionName)
{
if (_permissionMultipliers.ContainsKey(permissionName)) RecalculatePlayer(playerId);
}
private void OnUserPermissionRevoked(string playerId, string permissionName)
{
if (_permissionMultipliers.ContainsKey(permissionName)) RecalculatePlayer(playerId);
}
private void OnUserGroupAdded(string playerId, string groupName)
{
RecalculatePlayer(playerId);
}
private void OnUserGroupRemoved(string playerId, string groupName)
{
RecalculatePlayer(playerId);
}
private void RecalculateConnectedPlayers()
{
foreach (IPlayer player in players.Connected) InitializePlayer(player, false);
}
private void RecalculatePlayer(string playerId)
{
IPlayer player = players.FindPlayerById(playerId);
if (player != null && player.IsConnected) InitializePlayer(player, false);
}
#endregion
#region Payout Handling
private PayoutResult TryPayout(IPlayer player, double amount, string messageKey, bool requireActivity, string timeAlive)
{
if (player == null || !player.IsConnected || !IsFinite(amount) || amount <= 0d) return PayoutResult.Failed;
if (requireActivity && IsPlayerAfk(player))
{
if (_config.NotifyMissedPayouts) Message(player, "MissedPayout");
return PayoutResult.SkippedAfk;
}
bool success;
double creditedAmount = amount;
try
{
if (_config.CurrencyProvider == "Economics")
{
if (Economics == null || !Economics.IsLoaded)
{
WarnOnce("economics-missing", "Economics is unavailable; payout will be retried");
return PayoutResult.Failed;
}
object result = Economics.Call("Deposit", player.Id, amount);
success = result is bool && (bool)result;
}
else
{
if (ServerRewards == null || !ServerRewards.IsLoaded)
{
WarnOnce("serverrewards-missing", "ServerRewards is unavailable; payout will be retried");
return PayoutResult.Failed;
}
int points = Convert.ToInt32(Math.Round(amount, MidpointRounding.AwayFromZero));
if (points <= 0)
{
WarnOnce("serverrewards-rounding", "A ServerRewards payout rounded to zero and was not issued");
return PayoutResult.Failed;
}
object result = ServerRewards.Call("AddPoints", player.Id, points);
success = result is bool && (bool)result;
creditedAmount = points;
}
}
catch (Exception exception)
{
LogError($"Payout failed for {player.Name} ({player.Id}): {exception.Message}");
return PayoutResult.Failed;
}
if (!success)
{
WarnOnce("provider-rejected", $"{_config.CurrencyProvider} rejected a payout");
return PayoutResult.Failed;
}
if (_config.NotifySuccessfulPayouts)
{
string message = GetLang(messageKey, player.Id).Replace("$payout.amount", FormatAmount(creditedAmount));
if (timeAlive != null) message = message.Replace("$time.alive", timeAlive);
player.Reply(message);
}
return PayoutResult.Success;
}
private bool IsPlayerAfk(IPlayer player)
{
if (!_config.AfkApi || AFKAPI == null || !AFKAPI.IsLoaded) return false;
ulong playerId;
return ulong.TryParse(player.Id, out playerId) && AFKAPI.Call<bool>("IsPlayerAFK", playerId);
}
private void TryPendingRewards(IPlayer player)
{
PlayerInfo info = GetPlayerInfo(player.Id);
bool changed = false;
if (_config.WelcomeBonus > 0d && !info.WelcomeBonus &&
TryPayout(player, _config.WelcomeBonus, "ReceivedWelcomeBonus", false, null) == PayoutResult.Success)
{
info.WelcomeBonus = true;
changed = true;
}
if (info.PendingAliveBonus > 0d &&
TryPayout(player, info.PendingAliveBonus, "ReceivedForTimeAlive", false, FormatDuration(info.PendingAliveSeconds)) == PayoutResult.Success)
{
info.PendingAliveBonus = 0d;
info.PendingAliveSeconds = 0d;
changed = true;
}
if (changed) SaveData();
}
private void OnUserConnected(IPlayer player)
{
InitializePlayer(player, true);
if (player != null && player.Id.IsSteamId())
{
TryPendingRewards(player);
_payouts[player.Id].PendingRetryAt = Time.realtimeSinceStartup + Math.Min(30f, _config.PayoutInterval);
}
}
private void OnUserDisconnected(IPlayer player)
{
if (player == null) return;
_payouts.Remove(player.Id);
_lifeStartedAt.Remove(player.Id);
}
private void OnUserRespawned(IPlayer player)
{
if (player != null && player.Id.IsSteamId()) _lifeStartedAt[player.Id] = Time.realtimeSinceStartup;
}
private void OnPlayerDeath(BasePlayer basePlayer, HitInfo hitInfo)
{
if (!_config.TimeAliveBonus || basePlayer == null || basePlayer.IPlayer == null) return;
IPlayer player = basePlayer.IPlayer;
float startedAt;
if (!_lifeStartedAt.TryGetValue(player.Id, out startedAt)) return;
_lifeStartedAt.Remove(player.Id);
double secondsAlive = Math.Max(0d, Time.realtimeSinceStartup - startedAt);
if (secondsAlive <= 0d || _config.TimeAliveMultiplier <= 0d) return;
double amount = (secondsAlive / _config.PayoutInterval) * _config.BasePayout * _config.TimeAliveMultiplier;
if (!IsFinite(amount) || amount <= 0d) return;
PlayerInfo info = GetPlayerInfo(player.Id);
info.PendingAliveBonus += amount;
info.PendingAliveSeconds += secondsAlive;
SaveData();
TryPendingRewards(player);
}
#endregion
#region Commands and Helpers
[Command("moneytime")]
private void MoneyTimeCommand(IPlayer player, string command, string[] args)
{
if (player == null) return;
if (!player.IsServer && !player.HasPermission(AdminPermission))
{
Message(player, "NoPermission");
return;
}
if (args.Length == 0 || args[0].Equals("status", StringComparison.OrdinalIgnoreCase))
{
double multiplier = 1d;
if (!player.IsServer && player.Id.IsSteamId()) CalculatePayout(player, out multiplier);
string status = GetLang("Status", player.Id)
.Replace("$provider", _config.CurrencyProvider)
.Replace("$amount", FormatAmount(_config.BasePayout))
.Replace("$interval", _config.PayoutInterval.ToString(CultureInfo.InvariantCulture))
.Replace("$multiplier", FormatAmount(multiplier) + "x");
player.Reply(status);
return;
}
if (!args[0].Equals("resetwelcome", StringComparison.OrdinalIgnoreCase) || args.Length < 2)
{
Message(player, "AdminUsage");
return;
}
if (args[1].Equals("all", StringComparison.OrdinalIgnoreCase))
{
foreach (PlayerInfo info in _storedData.Players.Values)
if (info != null) info.WelcomeBonus = false;
SaveData();
Message(player, "WelcomeResetAll");
return;
}
string targetId = args[1];
if (!targetId.IsSteamId())
{
Message(player, "AdminUsage");
return;
}
GetPlayerInfo(targetId).WelcomeBonus = false;
SaveData();
player.Reply(GetLang("WelcomeResetPlayer", player.Id).Replace("$player.id", targetId));
}
private string GetLang(string key, string playerId = null)
{
return lang.GetMessage(key, this, playerId);
}
private void Message(IPlayer player, string key)
{
if (player != null) player.Reply(GetLang(key, player.Id));
}
private void WarnOnce(string key, string message)
{
if (_loggedWarnings.Add(key)) LogWarning(message);
}
private static string FormatAmount(double amount)
{
return amount.ToString("0.##", CultureInfo.InvariantCulture);
}
private static string FormatDuration(double totalSeconds)
{
TimeSpan duration = TimeSpan.FromSeconds(Math.Max(0d, totalSeconds));
int totalHours = (int)duration.TotalHours;
if (totalHours > 0) return $"{totalHours}h {duration.Minutes:00}m {duration.Seconds:00}s";
if (duration.Minutes > 0) return $"{duration.Minutes}m {duration.Seconds:00}s";
return $"{duration.Seconds}s";
}
#endregion
}
}