diff --git a/README.md b/README.md
index 5ba7364..a0181b6 100644
--- a/README.md
+++ b/README.md
@@ -101,6 +101,10 @@ Clans on the website come from Rust's **own** clan system, not from the uMod `Cl
is optional. The two share no data: a server running the plugin has two unrelated clan systems, and
only the game's own clans become Teams on the site.
+Wulf's `Economics` is optional too, and not in `overlay.toml`. Where it is loaded the site can pay
+coins from an event, show balances and a richest column, and let staff adjust a balance; where it is
+not, each of those says "Economics is not installed on this server".
+
## Diagnosing it
```
diff --git a/overlay.toml b/overlay.toml
index daa699b..fc2678b 100644
--- a/overlay.toml
+++ b/overlay.toml
@@ -35,7 +35,9 @@
# and the first player walk's fixes (PROTOCOL.md §19) — opened by a configuration
# save that answers at once and reports its reload as a `config.outcome` frame,
# and the step editor (PROTOCOL.md §20): `kits.uses`, and the one-time reward
-# messages a `perm.sync` carries as `notices`.
+# messages a `perm.sync` carries as `notices`; and Economics (PROTOCOL.md §20.3):
+# `economics.pay`, `economics.balances`, `economics.adjust`, the tally's
+# `balance` and the `economics.wiped` event.
protocol = 14
# ── Oxide compatibility ──────────────────────────────────────────────────────
diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs
index 1f61bd3..edda5ba 100644
--- a/overlay/oxide/plugins/RunicGateway.cs
+++ b/overlay/oxide/plugins/RunicGateway.cs
@@ -59,7 +59,10 @@ namespace Oxide.Plugins
///
///
/// 14 (PROTOCOL.md §20, the module's PLAN_REDESIGNS.md §2): kits.uses, and the
- /// one-time reward messages perm.sync carries as notices (D333).
+ /// one-time reward messages perm.sync carries as notices (D333); and
+ /// Economics (§20.3, PLAN_REDESIGNS.md §11.2): economics.pay,
+ /// economics.balances, economics.adjust, the tally's balance and the
+ /// economics.wiped event.
///
///
private const int ProtocolVersion = 14;
@@ -256,7 +259,8 @@ namespace Oxide.Plugins
"OnEnterZone", "OnExitZone", "OnRgDomesReady",
"OnRunicNpcDied", "OnRunicNpcHealth", "OnRunicNpcPlacementChanged",
"OnRunicNpcBossSpawned", "OnRunicNpcBossPhase", "OnRunicNpcBossDied",
- "OnPlayerSleepEnded"
+ "OnPlayerSleepEnded",
+ "OnEconomicsBalanceUpdated", "OnEconomicsDataWiped"
};
private void Init()
@@ -1055,6 +1059,21 @@ namespace Oxide.Plugins
HandleKitsUses(cmd, reqId);
break;
+ case "economics.pay":
+ // Protocol 14 (PROTOCOL.md §20.3, the module's PLAN_REDESIGNS.md §11.2). Coins
+ // from an event step, kept once paid (D335).
+ HandleEconomicsPay(cmd, reqId);
+ break;
+
+ case "economics.balances":
+ HandleEconomicsBalances(cmd, reqId);
+ break;
+
+ case "economics.adjust":
+ // Staff, from a player's admin page: deposit, withdraw or set.
+ HandleEconomicsAdjust(cmd, reqId);
+ break;
+
case "chat.say":
HandleChatSay(cmd, reqId);
break;
@@ -1653,6 +1672,10 @@ namespace Oxide.Plugins
frame["steamId"] = SteamIdOf(player);
frame["name"] = player.displayName;
EmitFrame(frame);
+
+ // A balance carried over from last wipe is known at once, without waiting for it to
+ // move (D336). Nothing when Economics is not loaded.
+ NoteBalance(player);
}
private void OnPlayerDisconnected(BasePlayer player, string reason)
@@ -2850,6 +2873,14 @@ namespace Oxide.Plugins
public int Explosives;
public int Missions;
+ ///
+ /// Protocol 14 (§11.2.2): the player's Economics balance, a value and not a change,
+ /// like the bests. Sent only when : it moved, or was read at
+ /// connect, since the last flush.
+ ///
+ public double Balance;
+ public bool HasBalance;
+
public void Gather(string shortname, int amount)
{
Add(Gathered, shortname, amount);
@@ -2900,7 +2931,8 @@ namespace Oxide.Plugins
&& AnimalKills == 0 && WeaponKills.Count == 0 && Headshots == 0
&& BestPvpM <= 0f && BestPveM <= 0f && ApcKills == 0 && HeliKills == 0
&& Crafted.Count == 0 && Built == 0 && Repaired == 0 && Healed == 0
- && Rockets == 0 && Explosives == 0 && Missions == 0 && NpcProfileKills.Count == 0;
+ && Rockets == 0 && Explosives == 0 && Missions == 0 && NpcProfileKills.Count == 0
+ && !HasBalance;
}
}
@@ -2918,6 +2950,23 @@ namespace Oxide.Plugins
return tally;
}
+ ///
+ /// The tally of a player who need not be online — an Economics balance reaches an offline
+ /// player too. It carries no name, and the frame it flushes has none.
+ ///
+ private Tally TallyForId(ulong userId)
+ {
+ Tally tally;
+
+ if (!_tallies.TryGetValue(userId, out tally))
+ {
+ tally = new Tally();
+ _tallies[userId] = tally;
+ }
+
+ return tally;
+ }
+
///
/// Emits one player.tally per player with anything to report, and clears what it
/// emitted.
@@ -3002,6 +3051,10 @@ namespace Oxide.Plugins
if (tally.NpcProfileKills.Count > 0)
frame["npcProfileKills"] = tally.NpcProfileKills;
+ // Protocol 14: the current balance, a value the site stores rather than sums.
+ if (tally.HasBalance)
+ frame["balance"] = Math.Round(tally.Balance, 2);
+
EmitFrame(frame);
}
@@ -5103,9 +5156,15 @@ namespace Oxide.Plugins
// Hello carries ZoneManager's flag list and both helpers' states, so a change to any
// of the four is said again rather than waiting for the next connect (§3.1, §3.2).
- if (IsZonePlugin(plugin) || IsRunicNpc(plugin))
+ // Economics too: hello carries its starting balance, which the richest column needs.
+ if (IsZonePlugin(plugin) || IsRunicNpc(plugin) || IsEconomics(plugin))
NextTick(ResendHello);
+ // Economics loaded after players connected: what they hold now is not otherwise known
+ // until it moves (D336).
+ if (IsEconomics(plugin))
+ NextTick(NoteOnlineBalances);
+
// BetterChat keeps third-party titles on its INSTANCE, so a reloaded BetterChat has
// forgotten our getter. Registering again is the only way back (§33.1).
if (plugin != null && plugin != this && plugin.Name == "BetterChat")
@@ -5128,7 +5187,7 @@ namespace Oxide.Plugins
if (plugin != null && plugin.Name == "BetterChat")
_titlesRegistered = false;
- if (IsZonePlugin(plugin) || IsRunicNpc(plugin))
+ if (IsZonePlugin(plugin) || IsRunicNpc(plugin) || IsEconomics(plugin))
NextTick(ResendHello);
ConfigWriteJob job = _configJob;
@@ -10496,14 +10555,19 @@ namespace Oxide.Plugins
///
private bool TellNotice(BasePlayer player, RewardNotice n)
{
- Plugin popup = n.Delivery == DeliveryPopup ? PopupPlugin() : null;
+ SayTo(player, n.Message, n.Delivery);
+ return _noticesTold.Add(n.Key);
+ }
+
+ /// Main thread. One line to one player, as a popup where one was asked for and can be shown.
+ private void SayTo(BasePlayer player, string message, string delivery)
+ {
+ Plugin popup = delivery == DeliveryPopup ? PopupPlugin() : null;
if (popup != null)
- popup.Call("CreatePopupNotification", n.Message, player, 0f);
+ popup.Call("CreatePopupNotification", message, player, 0f);
else
- player.ChatMessage(n.Message);
-
- return _noticesTold.Add(n.Key);
+ player.ChatMessage(message);
}
///
@@ -10514,7 +10578,13 @@ namespace Oxide.Plugins
{
MarkHook("OnPlayerSleepEnded");
- if (player == null || _notices.Count == 0)
+ if (player == null)
+ return;
+
+ // Coins paid while they were away (protocol 14, §20.3).
+ TellPayments(player);
+
+ if (_notices.Count == 0)
return;
string steamId = SteamIdOf(player);
@@ -10605,8 +10675,9 @@ namespace Oxide.Plugins
}
LoadNoticesTold();
+ LoadPayments();
- Puts($"rewards: {_runTallies.Count} tallies, {_credits.Count} kit credits and {_noticesTold.Count} told notices on record");
+ Puts($"rewards: {_runTallies.Count} tallies, {_credits.Count} kit credits, {_noticesTold.Count} told notices and {_payments.Count} Economics payments on record");
EnsureTallyTimer();
}
@@ -11871,6 +11942,724 @@ namespace Oxide.Plugins
Puts($"rewards: Kits wipes use counts at a new save, so {_credits.Count} kit credits start over");
}
+ // ── Economics (protocol 14, PROTOCOL.md §20.3; the module's PLAN_REDESIGNS.md §11.2) ────
+ //
+ // Wulf's Economics, optional like the other mods (D199): every verb below answers
+ // `economics-missing` without it, and nothing else in this plugin needs it. Its API is five
+ // private methods, each with a `string` and an `object` overload, called by name. What the
+ // spike found (§11.2.1) shapes every call here:
+ //
+ // * an amount is boxed as a `double` — an `int` matches no overload, and `Call` answers
+ // null having done nothing;
+ // * only ids parsed as Steam ids are sent, because Economics makes an account of any
+ // string it is given;
+ // * what a deposit paid is the balance after less the balance before, because Deposit
+ // answers `true` while rounding to two places and clamping to its balance limit.
+ //
+ // Coins paid are kept (D335): there is no revert. A payment or adjustment is remembered by
+ // its key for seven days, so a retried step or a resent form is answered from the first
+ // reply and never pays twice.
+
+ private const double EconomicsAmountMin = 0.01;
+ private const double EconomicsAmountMax = 1000000d;
+
+ /// A deposit that moved the balance by less than this moved nothing: it rounds to two places.
+ private const double EconomicsMovedMin = 0.005;
+
+ /// How long a payment's key and reply are kept: seven days, the tally ceiling (D108).
+ private const long PaymentMemoryMs = TallyMaxHoldMs;
+
+ /// The longest payment key. The site's are short (runId:stepIndex).
+ private const int PaymentKeyMax = 128;
+
+ /// The lowest individual Steam id. Anything below it is not a player.
+ private const ulong SteamIdBase = 76561197960265728UL;
+
+ private const string EconomicsPay = "pay";
+ private const string EconomicsDeposit = "deposit";
+ private const string EconomicsWithdraw = "withdraw";
+ private const string EconomicsSet = "set";
+
+ ///
+ /// One payment (economics.pay) or one staff adjustment (economics.adjust),
+ /// kept with what it did so a repeat of its key is answered the same. Persisted as it is.
+ ///
+ private sealed class EconomicsPayment
+ {
+ public string Key;
+
+ /// pay, or the adjustment's deposit, withdraw or set.
+ public string Op;
+
+ public long AtMs;
+ public double Amount;
+
+ /// A payment's message, {amount} not yet filled in. Blank tells nobody.
+ public string Message;
+
+ public string Delivery;
+ public List Paid = new List();
+ public List Failed = new List();
+ }
+
+ private sealed class EconomicsPaid
+ {
+ public string SteamId;
+ public double Before;
+ public double After;
+
+ /// Whether the payment's message has been said to this player (always, when there is none).
+ public bool Told;
+ }
+
+ private sealed class EconomicsFailed
+ {
+ public string SteamId;
+ public string Reason;
+ }
+
+ /// What Economics' own config says, read from its file because its API does not.
+ private sealed class EconomicsSettings
+ {
+ public double? StartingBalance;
+ public bool? WipesWithMap;
+ }
+
+ /// Payments and adjustments of the last seven days, by .
+ private Dictionary _payments = new Dictionary(StringComparer.Ordinal);
+
+ private EconomicsSettings _economicsSettings;
+ private DateTime _economicsSettingsAt;
+
+ private Plugin EconomicsPlugin()
+ {
+ return plugins.Find("Economics");
+ }
+
+ private bool IsEconomics(Plugin plugin)
+ {
+ return plugin != null && plugin != this && plugin.Name == "Economics";
+ }
+
+ ///
+ /// Hello's integrations.economics: loaded and version like every optional mod, and
+ /// the two settings the site needs from Economics' config. Each is absent when the file
+ /// does not say, and the site then assumes Economics' defaults.
+ ///
+ private Dictionary EconomicsBody()
+ {
+ Plugin eco = EconomicsPlugin();
+ Dictionary body = IntegrationBody(eco);
+
+ if (eco == null)
+ return body;
+
+ EconomicsSettings settings = EconomicsConfig();
+
+ if (settings != null && settings.StartingBalance.HasValue)
+ body["startingBalance"] = settings.StartingBalance.Value;
+
+ if (settings != null && settings.WipesWithMap.HasValue)
+ body["wipesWithMap"] = settings.WipesWithMap.Value;
+
+ return body;
+ }
+
+ ///
+ /// Economics' config, read again only when the file changes: every status frame asks.
+ /// A file that will not parse is said once per version of it, and answers nothing. On
+ /// Carbon the file is under carbon/configs, so the directory comes from the
+ /// framework (R19), as Kits' does.
+ ///
+ private EconomicsSettings EconomicsConfig()
+ {
+ string path = Path.Combine(Interface.Oxide.ConfigDirectory, "Economics.json");
+ DateTime at;
+
+ try
+ {
+ if (!File.Exists(path))
+ return null;
+
+ at = File.GetLastWriteTimeUtc(path);
+ }
+ catch (Exception)
+ {
+ return null;
+ }
+
+ if (_economicsSettings != null && at == _economicsSettingsAt)
+ return _economicsSettings;
+
+ var settings = new EconomicsSettings();
+
+ try
+ {
+ var config = Newtonsoft.Json.Linq.JObject.Parse(File.ReadAllText(path));
+ Newtonsoft.Json.Linq.JToken start = config["Starting account balance (0 or higher)"];
+ Newtonsoft.Json.Linq.JToken wipe = config["Wipe balances on new save file"];
+
+ if (start != null && (start.Type == Newtonsoft.Json.Linq.JTokenType.Integer || start.Type == Newtonsoft.Json.Linq.JTokenType.Float))
+ settings.StartingBalance = (double)start;
+
+ if (wipe != null && wipe.Type == Newtonsoft.Json.Linq.JTokenType.Boolean)
+ settings.WipesWithMap = (bool)wipe;
+ }
+ catch (Exception ex)
+ {
+ Puts($"could not read Economics' config: {Flatten(ex.Message)}");
+ }
+
+ _economicsSettings = settings;
+ _economicsSettingsAt = at;
+ return settings;
+ }
+
+ /// Main thread. A balance to two places, or null when Economics did not answer with one.
+ private static double? ReadBalance(Plugin eco, string steamId)
+ {
+ object value = eco.Call("Balance", steamId);
+ return value is double ? (double?)Math.Round((double)value, 2) : null;
+ }
+
+ /// A Steam id as Economics should see it, or false. Economics checks none itself.
+ private static bool TryParseSteamId(string raw, out ulong id)
+ {
+ return ulong.TryParse(raw, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out id)
+ && id >= SteamIdBase
+ && raw.Length == 17;
+ }
+
+ private static string Coins(double amount)
+ {
+ return amount.ToString("#,0.##", System.Globalization.CultureInfo.InvariantCulture);
+ }
+
+ private static bool AmountIn(double amount, double min)
+ {
+ return !double.IsNaN(amount) && !double.IsInfinity(amount) && amount >= min && amount <= EconomicsAmountMax;
+ }
+
+ ///
+ /// Where a payment or an adjustment is remembered. The two keep apart, so a site's step
+ /// key can never be answered with a staff adjustment's reply; the three adjustments share
+ /// one, so a key is one adjustment whatever its op.
+ ///
+ private static string PaymentSlot(string op, string key)
+ {
+ return (op == EconomicsPay ? "pay|" : "adjust|") + key;
+ }
+
+ /// Main thread. One player's balance into their tally, from their connect or Economics loading.
+ private void NoteBalance(BasePlayer player)
+ {
+ Plugin eco = EconomicsPlugin();
+
+ if (eco == null || player == null)
+ return;
+
+ double? balance = ReadBalance(eco, SteamIdOf(player));
+
+ if (!balance.HasValue)
+ return;
+
+ Tally tally = TallyFor(player);
+ tally.Balance = balance.Value;
+ tally.HasBalance = true;
+ }
+
+ private void NoteOnlineBalances()
+ {
+ foreach (BasePlayer player in BasePlayer.activePlayerList)
+ NoteBalance(player);
+ }
+
+ ///
+ /// Every balance change, deposits and withdrawals included, raised before their own hooks.
+ /// Folded into the tally as a value (rule 2: aggregates only). The player need not be
+ /// online: a payment or a staff adjustment reaches an offline player, and their frame
+ /// then has no name.
+ ///
+ private void OnEconomicsBalanceUpdated(string playerId, double balance)
+ {
+ MarkHook("OnEconomicsBalanceUpdated");
+
+ ulong id;
+
+ if (!TryParseSteamId(playerId, out id))
+ return;
+
+ // Named only while connected: a sleeper is found too, and a name on the frame is what
+ // tells the site a player was here (it moves their last-seen). A balance is not play.
+ BasePlayer player = BasePlayer.FindByID(id);
+ Tally tally = player != null && player.IsConnected ? TallyFor(player) : TallyForId(id);
+ tally.Balance = Math.Round(balance, 2);
+ tally.HasBalance = true;
+ }
+
+ ///
+ /// Raised with no arguments when a new save wipes the balances (Economics' own setting),
+ /// and with the caller after ecowipe. One method taking an IPlayer binds
+ /// both on both frameworks, with null for the first (§11.2.1). A balance not yet flushed
+ /// is from before the wipe, so it is dropped.
+ ///
+ private void OnEconomicsDataWiped(Oxide.Core.Libraries.Covalence.IPlayer player)
+ {
+ MarkHook("OnEconomicsDataWiped");
+
+ foreach (Tally tally in _tallies.Values)
+ tally.HasBalance = false;
+
+ Dictionary frame = Frame("economics.wiped", "event");
+
+ // A new save's wipe opens a new map, whose balances the site has not seen yet; the
+ // last wipe's are a record of how it ended. `ecowipe` clears the wipe in play.
+ frame["newSave"] = player == null;
+
+ EmitFrame(frame);
+ Puts($"Economics wiped every balance ({(player == null ? "new save" : "ecowipe")})");
+ }
+
+ ///
+ /// Main thread. Coins from an event step to a list of players, kept once paid (D335). One
+ /// call for the whole list. A key already paid is answered from its first reply with
+ /// repeat: true, and pays nobody.
+ ///
+ private void HandleEconomicsPay(Dictionary cmd, string reqId)
+ {
+ Plugin eco = EconomicsPlugin();
+
+ if (eco == null)
+ {
+ EmitEconomicsError(reqId, "economics-missing", "Economics is not installed on this server", null);
+ return;
+ }
+
+ if (_config == null || !_config.EventsEnabled)
+ {
+ EmitEconomicsError(reqId, "events-disabled",
+ "events are switched off on this server — set EventsEnabled in the RunicGateway plugin config", null);
+ return;
+ }
+
+ string key = StringField(cmd, "key");
+
+ if (string.IsNullOrEmpty(key) || key.Length > PaymentKeyMax)
+ {
+ EmitEconomicsError(reqId, "malformed", "a payment has a key", null);
+ return;
+ }
+
+ ForgetOldPayments();
+
+ EconomicsPayment done;
+
+ if (_payments.TryGetValue(PaymentSlot(EconomicsPay, key), out done))
+ {
+ EmitPaid(done, reqId, true);
+ return;
+ }
+
+ double amount = DoubleField(cmd, "amount") ?? double.NaN;
+
+ if (!AmountIn(amount, EconomicsAmountMin))
+ {
+ EmitEconomicsError(reqId, "bad-amount",
+ $"a payment is {Coins(EconomicsAmountMin)} to {Coins(EconomicsAmountMax)} coins", null);
+ return;
+ }
+
+ var payment = new EconomicsPayment
+ {
+ Key = key,
+ Op = EconomicsPay,
+ AtMs = NowMs(),
+ Amount = amount,
+ Message = Flatten(StringField(cmd, "message") ?? "").Trim(),
+ Delivery = StringField(cmd, "delivery") == DeliveryPopup ? DeliveryPopup : DeliveryChat
+ };
+
+ var ids = new List();
+ var seen = new HashSet(StringComparer.Ordinal);
+
+ foreach (string raw in Strings(cmd, "steamIds"))
+ {
+ ulong id;
+
+ if (!seen.Add(raw))
+ continue;
+
+ if (TryParseSteamId(raw, out id))
+ ids.Add(raw);
+ else
+ payment.Failed.Add(new EconomicsFailed { SteamId = raw, Reason = "bad-id" });
+ }
+
+ if (seen.Count > MaxRecipients())
+ {
+ EmitEconomicsError(reqId, "too-large", $"this server pays at most {MaxRecipients()} players in one step, not {seen.Count}", null);
+ return;
+ }
+
+ foreach (string id in ids)
+ {
+ double? before = ReadBalance(eco, id);
+ object ok = eco.Call("Deposit", id, amount);
+ double? after = ReadBalance(eco, id);
+
+ if (!(ok is bool) || !(bool)ok)
+ {
+ payment.Failed.Add(new EconomicsFailed { SteamId = id, Reason = "refused" });
+ continue;
+ }
+
+ if (!before.HasValue || !after.HasValue)
+ {
+ // Deposited, but not measured. Listed as failed so nobody is told an amount,
+ // and remembered with the key so a retry still pays nothing more.
+ payment.Failed.Add(new EconomicsFailed { SteamId = id, Reason = "unmeasured" });
+ continue;
+ }
+
+ if (after.Value - before.Value < EconomicsMovedMin)
+ {
+ payment.Failed.Add(new EconomicsFailed { SteamId = id, Reason = "at-limit" });
+ continue;
+ }
+
+ var paid = new EconomicsPaid { SteamId = id, Before = before.Value, After = after.Value, Told = payment.Message.Length == 0 };
+ payment.Paid.Add(paid);
+
+ BasePlayer awake = AwakePlayer(id);
+
+ if (awake != null && !paid.Told)
+ TellPaid(awake, payment, paid);
+ }
+
+ _payments[PaymentSlot(EconomicsPay, key)] = payment;
+ SavePayments();
+
+ _lastRewardSummary = $"paid {Coins(amount)} coins to {payment.Paid.Count} player(s), {payment.Failed.Count} failed";
+ EmitPaid(payment, reqId, false);
+ }
+
+ /// Main thread. Of the given players, each one's balance, for the account and admin pages.
+ private void HandleEconomicsBalances(Dictionary cmd, string reqId)
+ {
+ Plugin eco = EconomicsPlugin();
+
+ if (eco == null)
+ {
+ EmitEconomicsError(reqId, "economics-missing", "Economics is not installed on this server", null);
+ return;
+ }
+
+ var ids = new List();
+ var seen = new HashSet(StringComparer.Ordinal);
+
+ foreach (string raw in Strings(cmd, "steamIds"))
+ {
+ ulong id;
+
+ if (TryParseSteamId(raw, out id) && seen.Add(raw))
+ ids.Add(raw);
+ }
+
+ if (ids.Count > PermMaxOps)
+ {
+ EmitEconomicsError(reqId, "too-large", $"at most {PermMaxOps} players in one question, not {ids.Count}", null);
+ return;
+ }
+
+ var rows = new List