feat(bridge): Economics on protocol 14 — pay, balances, adjust (PLAN_REDESIGNS §11.2) #31

Merged
whitlocktech merged 1 commits from feat/protocol-14-economics into edge 2026-10-12 07:40:25 -05:00
3 changed files with 810 additions and 14 deletions

View File

@@ -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
```

View File

@@ -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 ──────────────────────────────────────────────────────

View File

@@ -59,7 +59,10 @@ namespace Oxide.Plugins
///
/// <para>
/// 14 (PROTOCOL.md §20, the module's PLAN_REDESIGNS.md §2): <c>kits.uses</c>, and the
/// one-time reward messages <c>perm.sync</c> carries as <c>notices</c> (D333).
/// one-time reward messages <c>perm.sync</c> carries as <c>notices</c> (D333); and
/// Economics (§20.3, PLAN_REDESIGNS.md §11.2): <c>economics.pay</c>,
/// <c>economics.balances</c>, <c>economics.adjust</c>, the tally's <c>balance</c> and the
/// <c>economics.wiped</c> event.
/// </para>
/// </summary>
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;
/// <summary>
/// Protocol 14 (§11.2.2): the player's Economics balance, a value and not a change,
/// like the bests. Sent only when <see cref="HasBalance"/>: it moved, or was read at
/// connect, since the last flush.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
private Tally TallyForId(ulong userId)
{
Tally tally;
if (!_tallies.TryGetValue(userId, out tally))
{
tally = new Tally();
_tallies[userId] = tally;
}
return tally;
}
/// <summary>
/// Emits one <c>player.tally</c> 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
/// </summary>
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);
}
/// <summary>Main thread. One line to one player, as a popup where one was asked for and can be shown.</summary>
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);
}
/// <summary>
@@ -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;
/// <summary>A deposit that moved the balance by less than this moved nothing: it rounds to two places.</summary>
private const double EconomicsMovedMin = 0.005;
/// <summary>How long a payment's key and reply are kept: seven days, the tally ceiling (D108).</summary>
private const long PaymentMemoryMs = TallyMaxHoldMs;
/// <summary>The longest payment key. The site's are short (<c>runId:stepIndex</c>).</summary>
private const int PaymentKeyMax = 128;
/// <summary>The lowest individual Steam id. Anything below it is not a player.</summary>
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";
/// <summary>
/// One payment (<c>economics.pay</c>) or one staff adjustment (<c>economics.adjust</c>),
/// kept with what it did so a repeat of its key is answered the same. Persisted as it is.
/// </summary>
private sealed class EconomicsPayment
{
public string Key;
/// <summary><c>pay</c>, or the adjustment's <c>deposit</c>, <c>withdraw</c> or <c>set</c>.</summary>
public string Op;
public long AtMs;
public double Amount;
/// <summary>A payment's message, <c>{amount}</c> not yet filled in. Blank tells nobody.</summary>
public string Message;
public string Delivery;
public List<EconomicsPaid> Paid = new List<EconomicsPaid>();
public List<EconomicsFailed> Failed = new List<EconomicsFailed>();
}
private sealed class EconomicsPaid
{
public string SteamId;
public double Before;
public double After;
/// <summary>Whether the payment's message has been said to this player (always, when there is none).</summary>
public bool Told;
}
private sealed class EconomicsFailed
{
public string SteamId;
public string Reason;
}
/// <summary>What Economics' own config says, read from its file because its API does not.</summary>
private sealed class EconomicsSettings
{
public double? StartingBalance;
public bool? WipesWithMap;
}
/// <summary>Payments and adjustments of the last seven days, by <see cref="PaymentSlot"/>.</summary>
private Dictionary<string, EconomicsPayment> _payments = new Dictionary<string, EconomicsPayment>(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";
}
/// <summary>
/// Hello's <c>integrations.economics</c>: 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.
/// </summary>
private Dictionary<string, object> EconomicsBody()
{
Plugin eco = EconomicsPlugin();
Dictionary<string, object> 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;
}
/// <summary>
/// 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 <c>carbon/configs</c>, so the directory comes from the
/// framework (R19), as Kits' does.
/// </summary>
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;
}
/// <summary>Main thread. A balance to two places, or null when Economics did not answer with one.</summary>
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;
}
/// <summary>A Steam id as Economics should see it, or false. Economics checks none itself.</summary>
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;
}
/// <summary>
/// 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.
/// </summary>
private static string PaymentSlot(string op, string key)
{
return (op == EconomicsPay ? "pay|" : "adjust|") + key;
}
/// <summary>Main thread. One player's balance into their tally, from their connect or Economics loading.</summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// Raised with no arguments when a new save wipes the balances (Economics' own setting),
/// and with the caller after <c>ecowipe</c>. One method taking an <c>IPlayer</c> 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.
/// </summary>
private void OnEconomicsDataWiped(Oxide.Core.Libraries.Covalence.IPlayer player)
{
MarkHook("OnEconomicsDataWiped");
foreach (Tally tally in _tallies.Values)
tally.HasBalance = false;
Dictionary<string, object> 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")})");
}
/// <summary>
/// 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
/// <c>repeat: true</c>, and pays nobody.
/// </summary>
private void HandleEconomicsPay(Dictionary<string, object> 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<string>();
var seen = new HashSet<string>(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);
}
/// <summary>Main thread. Of the given players, each one's balance, for the account and admin pages.</summary>
private void HandleEconomicsBalances(Dictionary<string, object> 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<string>();
var seen = new HashSet<string>(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<object>();
foreach (string id in ids)
{
double? balance = ReadBalance(eco, id);
if (balance.HasValue)
rows.Add(new Dictionary<string, object> { { "steamId", id }, { "balance", balance.Value } });
}
Dictionary<string, object> 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);
}
/// <summary>
/// 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.
/// </summary>
private void HandleEconomicsAdjust(Dictionary<string, object> 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<object>();
var failed = new List<object>();
foreach (EconomicsPaid row in payment.Paid)
paid.Add(new Dictionary<string, object> { { "steamId", row.SteamId }, { "before", row.Before }, { "after", row.After } });
foreach (EconomicsFailed row in payment.Failed)
failed.Add(new Dictionary<string, object> { { "steamId", row.SteamId }, { "reason", row.Reason } });
Dictionary<string, object> 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<string, object> 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<string, object> 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);
}
/// <summary>Main thread. Says a payment's message to one player, with what they received, and marks it told.</summary>
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;
}
/// <summary>Main thread, as a player wakes. Every payment they have not been told of yet.</summary>
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();
}
/// <summary>Forgets payments seven days old, with any message nobody woke up to hear. True when any went.</summary>
private bool ForgetOldPayments()
{
long now = NowMs();
var stale = new List<string>();
foreach (KeyValuePair<string, EconomicsPayment> 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<EconomicsPayment>(_payments.Values), "Economics payments");
}
private void LoadPayments()
{
_payments = new Dictionary<string, EconomicsPayment>(StringComparer.Ordinal);
List<EconomicsPayment> payments = ReadDataFile<EconomicsPayment>(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<EconomicsPaid>();
if (p.Failed == null)
p.Failed = new List<EconomicsFailed>();
if (p.Message == null)
p.Message = "";
_payments[PaymentSlot(p.Op, p.Key)] = p;
}
ForgetOldPayments();
}
// ── Chat ──────────────────────────────────────────────────────────────────
/// <summary>
@@ -12308,7 +13097,8 @@ namespace Oxide.Plugins
{ "betterChat", IntegrationBody(BetterChatPlugin()) },
{ "popupNotifications", IntegrationBody(PopupPlugin()) },
{ "zoneDomes", ZoneDomesBody() },
{ "runicNpc", RunicNpcBody() }
{ "runicNpc", RunicNpcBody() },
{ "economics", EconomicsBody() }
};
}