From 66fbbd6b571c501734db278d062595eeae34eb95 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 12 Oct 2026 06:52:52 -0500 Subject: [PATCH] =?UTF-8?q?feat(bridge):=20Economics=20on=20protocol=2014?= =?UTF-8?q?=20=E2=80=94=20pay,=20balances,=20adjust=20(PLAN=5FREDESIGNS=20?= =?UTF-8?q?=C2=A711.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wulf's Economics as an optional integration (D199), as §11.2.2 designs it: - hello/status `integrations.economics` adds startingBalance and wipesWithMap, read from Economics.json (cached by mtime) because its API has neither. - `economics.pay`: coins from an event step, kept once paid (D335). One main- thread call for the list; each deposit measured as after - before, since Deposit answers true while rounding and clamping (`at-limit` otherwise). The key and its reply are kept in data/RunicGateway/payments.json for seven days, so a retry answers `repeat: true` and pays nobody. The optional message ({amount} = what landed) is told now if awake, else on wake. - `economics.balances` (bounded by PermMaxOps) and staff `economics.adjust` (deposit / withdraw / set; a withdrawal above the balance is refused `insufficient` with the balance). - `player.tally` gains `balance`, a value: read at connect (D336) and from OnEconomicsBalanceUpdated, offline players included. - `economics.wiped` from OnEconomicsDataWiped(IPlayer), with `newSave`. Amounts are boxed as double and only Steam ids are sent, per the spike. Compiled on both rigs (Oxide 2.0.7826, Carbon 2.0.262), then restored. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- README.md | 4 + overlay.toml | 4 +- overlay/oxide/plugins/RunicGateway.cs | 816 +++++++++++++++++++++++++- 3 files changed, 810 insertions(+), 14 deletions(-) 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(); + + foreach (string id in ids) + { + double? balance = ReadBalance(eco, id); + + if (balance.HasValue) + rows.Add(new Dictionary { { "steamId", id }, { "balance", balance.Value } }); + } + + Dictionary reply = Frame("economics.balances", "reply"); + reply["balances"] = rows; + + // A balance at the starting value may be an account Economics has never seen (§11.2.1). + EconomicsSettings settings = EconomicsConfig(); + + if (settings != null && settings.StartingBalance.HasValue) + reply["startingBalance"] = settings.StartingBalance.Value; + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + /// + /// Main thread. Staff: deposit, withdraw or set one player's balance. A withdrawal above the + /// balance is refused with the balance, because Economics takes all or nothing. + /// + private void HandleEconomicsAdjust(Dictionary cmd, string reqId) + { + Plugin eco = EconomicsPlugin(); + + if (eco == null) + { + EmitEconomicsError(reqId, "economics-missing", "Economics is not installed on this server", null); + return; + } + + string key = StringField(cmd, "key"); + + if (string.IsNullOrEmpty(key) || key.Length > PaymentKeyMax) + { + EmitEconomicsError(reqId, "malformed", "an adjustment has a key", null); + return; + } + + string op = StringField(cmd, "op"); + string method = op == EconomicsDeposit ? "Deposit" : op == EconomicsWithdraw ? "Withdraw" : op == EconomicsSet ? "SetBalance" : null; + + if (method == null) + { + EmitEconomicsError(reqId, "malformed", "an adjustment deposits, withdraws or sets", null); + return; + } + + ForgetOldPayments(); + + EconomicsPayment done; + + if (_payments.TryGetValue(PaymentSlot(op, key), out done)) + { + EmitAdjusted(done, reqId, true); + return; + } + + string steamId = StringField(cmd, "steamId"); + ulong parsed; + + if (!TryParseSteamId(steamId, out parsed)) + { + EmitEconomicsError(reqId, "malformed", "an adjustment names one player by Steam id", null); + return; + } + + double amount = DoubleField(cmd, "amount") ?? double.NaN; + + if (!AmountIn(amount, op == EconomicsSet ? 0d : EconomicsAmountMin)) + { + EmitEconomicsError(reqId, "bad-amount", + $"an adjustment is {(op == EconomicsSet ? "0" : Coins(EconomicsAmountMin))} to {Coins(EconomicsAmountMax)} coins", null); + return; + } + + double? before = ReadBalance(eco, steamId); + object ok = eco.Call(method, steamId, amount); + double? after = ReadBalance(eco, steamId); + + if (!(ok is bool) || !(bool)ok) + { + if (op == EconomicsWithdraw && before.HasValue && amount > before.Value) + EmitEconomicsError(reqId, "insufficient", $"this player has only {Coins(before.Value)} coins", before); + else + EmitEconomicsError(reqId, "refused", $"Economics refused to {op} {Coins(amount)} coins", before); + + return; + } + + if (!before.HasValue || !after.HasValue) + { + EmitEconomicsError(reqId, "refused", "Economics did not report this player's balance", null); + return; + } + + if (op == EconomicsDeposit && after.Value - before.Value < EconomicsMovedMin) + { + EmitEconomicsError(reqId, "at-limit", "this player's balance is at Economics' limit", before); + return; + } + + var adjustment = new EconomicsPayment + { + Key = key, + Op = op, + AtMs = NowMs(), + Amount = amount + }; + + adjustment.Paid.Add(new EconomicsPaid { SteamId = steamId, Before = before.Value, After = after.Value, Told = true }); + + _payments[PaymentSlot(op, key)] = adjustment; + SavePayments(); + + _lastRewardSummary = $"{op} {Coins(amount)} coins for {steamId}: {Coins(before.Value)} → {Coins(after.Value)}"; + EmitAdjusted(adjustment, reqId, false); + } + + private void EmitPaid(EconomicsPayment payment, string reqId, bool repeat) + { + var paid = new List(); + var failed = new List(); + + foreach (EconomicsPaid row in payment.Paid) + paid.Add(new Dictionary { { "steamId", row.SteamId }, { "before", row.Before }, { "after", row.After } }); + + foreach (EconomicsFailed row in payment.Failed) + failed.Add(new Dictionary { { "steamId", row.SteamId }, { "reason", row.Reason } }); + + Dictionary reply = Frame("economics.paid", "reply"); + reply["key"] = payment.Key; + reply["repeat"] = repeat; + reply["amount"] = payment.Amount; + reply["paid"] = paid; + reply["failed"] = failed; + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + private void EmitAdjusted(EconomicsPayment adjustment, string reqId, bool repeat) + { + EconomicsPaid row = adjustment.Paid.Count > 0 ? adjustment.Paid[0] : null; + + Dictionary reply = Frame("economics.adjusted", "reply"); + reply["key"] = adjustment.Key; + reply["repeat"] = repeat; + reply["op"] = adjustment.Op; + reply["amount"] = adjustment.Amount; + + if (row != null) + { + reply["steamId"] = row.SteamId; + reply["before"] = row.Before; + reply["after"] = row.After; + } + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + private void EmitEconomicsError(string reqId, string reason, string message, double? balance) + { + Dictionary frame = Frame("economics.error", "reply"); + frame["reason"] = reason; + frame["message"] = message; + + if (balance.HasValue) + frame["balance"] = balance.Value; + + if (reqId != null) + frame["reqId"] = reqId; + + _lastRewardSummary = $"refused ({reason}): {message}"; + EmitFrame(frame); + } + + /// Main thread. Says a payment's message to one player, with what they received, and marks it told. + private void TellPaid(BasePlayer player, EconomicsPayment payment, EconomicsPaid row) + { + string line = payment.Message.Replace("{amount}", Coins(row.After - row.Before)); + + if (line.Length > MaxChat()) + line = line.Substring(0, MaxChat()); + + SayTo(player, line, payment.Delivery); + row.Told = true; + } + + /// Main thread, as a player wakes. Every payment they have not been told of yet. + private void TellPayments(BasePlayer player) + { + if (_payments.Count == 0) + return; + + string steamId = SteamIdOf(player); + bool changed = false; + + foreach (EconomicsPayment payment in _payments.Values) + { + if (payment.Op != EconomicsPay || string.IsNullOrEmpty(payment.Message)) + continue; + + foreach (EconomicsPaid row in payment.Paid) + { + if (row.SteamId == steamId && !row.Told) + { + TellPaid(player, payment, row); + changed = true; + } + } + } + + if (changed) + SavePayments(); + } + + /// Forgets payments seven days old, with any message nobody woke up to hear. True when any went. + private bool ForgetOldPayments() + { + long now = NowMs(); + var stale = new List(); + + foreach (KeyValuePair kv in _payments) + { + if (now >= kv.Value.AtMs + PaymentMemoryMs) + stale.Add(kv.Key); + } + + foreach (string slot in stale) + _payments.Remove(slot); + + if (stale.Count > 0) + SavePayments(); + + return stale.Count > 0; + } + + private string PaymentsPath() + { + return Path.Combine(Interface.Oxide.DataDirectory, Name, "payments.json"); + } + + private void SavePayments() + { + WriteDataFile(PaymentsPath(), new List(_payments.Values), "Economics payments"); + } + + private void LoadPayments() + { + _payments = new Dictionary(StringComparer.Ordinal); + List payments = ReadDataFile(PaymentsPath(), "Economics payments"); + + if (payments == null) + return; + + foreach (EconomicsPayment p in payments) + { + if (p == null || string.IsNullOrEmpty(p.Key) || string.IsNullOrEmpty(p.Op)) + continue; + + if (p.Paid == null) + p.Paid = new List(); + + if (p.Failed == null) + p.Failed = new List(); + + if (p.Message == null) + p.Message = ""; + + _payments[PaymentSlot(p.Op, p.Key)] = p; + } + + ForgetOldPayments(); + } + // ── Chat ────────────────────────────────────────────────────────────────── /// @@ -12308,7 +13097,8 @@ namespace Oxide.Plugins { "betterChat", IntegrationBody(BetterChatPlugin()) }, { "popupNotifications", IntegrationBody(PopupPlugin()) }, { "zoneDomes", ZoneDomesBody() }, - { "runicNpc", RunicNpcBody() } + { "runicNpc", RunicNpcBody() }, + { "economics", EconomicsBody() } }; } -- 2.49.1